From 1b17304c4a0e8f6bc8ba998561c24f9d95326ca9 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:17:40 +0100 Subject: [PATCH 001/129] docs(installation): add PowerShell commands for Prowler App install (#11413) --- README.md | 12 ++++++++++++ docs/getting-started/installation/prowler-app.mdx | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eff78169b0..d2808daf84 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ Prowler App offers flexible installation methods tailored to various environment #### Commands +_macOS/Linux:_ + ``` console VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" @@ -161,6 +163,16 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V docker compose up -d ``` +_Windows PowerShell:_ + +``` powershell +$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" +# Environment variables can be customized in the .env file. Using default values in production environments is not recommended. +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" +docker compose up -d +``` + > [!WARNING] > 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 3f19329914..dea9c09446 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai _Commands_: - ```bash + + ```bash macOS/Linux VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. @@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai docker compose up -d ``` + ```powershell Windows PowerShell + $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" + # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" + docker compose up -d + ``` + + For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. From a652e28b4a6aede7b36190c37fefd7fdcdb6cfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 2 Jun 2026 11:37:05 +0200 Subject: [PATCH 002/129] fix(api): clean up scan tmp output failure to avoid disk fill (#11421) Co-authored-by: Pepe Fagoaga --- api/CHANGELOG.md | 8 ++ api/src/backend/api/tests/test_views.py | 164 ++++++++++++++++++++-- api/src/backend/api/v1/views.py | 34 +++-- api/src/backend/tasks/tasks.py | 30 +++- api/src/backend/tasks/tests/test_tasks.py | 34 +++++ prowler/CHANGELOG.md | 8 ++ prowler/lib/outputs/ocsf/ocsf.py | 8 ++ tests/lib/outputs/ocsf/ocsf_test.py | 32 +++++ 8 files changed, 292 insertions(+), 26 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bbc675c8d2..fbc7f178ad 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.30.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + ## [1.30.0] (Prowler v5.29.0) ### 🔄 Changed diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ebd9128407..9e0b4362a3 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3856,16 +3856,20 @@ class TestScanViewSet: scan.output_location = "dummy" scan.save() - dummy_task = Task.objects.create(tenant_id=scan.tenant_id) - dummy_task.id = "dummy-task-id" - dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING} + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) + dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING} - with ( - patch("api.v1.views.Task.objects.get", return_value=dummy_task), - patch( - "api.v1.views.TaskSerializer", - return_value=type("DummySerializer", (), {"data": dummy_task_data}), - ), + with patch( + "api.v1.views.TaskSerializer", + return_value=type("DummySerializer", (), {"data": dummy_task_data}), ): url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) @@ -4186,6 +4190,88 @@ class TestScanViewSet: assert resp.status_code == status.HTTP_302_FOUND assert resp["Location"] == presigned_url + def test_compliance_s3_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """When several files match, the most recently modified one is served.""" + scan = scans_fixture[0] + bucket = "bucket" + scan.output_location = f"s3://{bucket}/path/scan.zip" + scan.state = StateChoices.COMPLETED + scan.save() + + monkeypatch.setattr( + "api.v1.views.env", + type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(), + ) + + old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv" + latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv" + + class FakeS3Client: + def list_objects_v2(self, Bucket, Prefix): + return { + "Contents": [ + { + "Key": old_key, + "LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + { + "Key": latest_key, + "LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc), + }, + ] + } + + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert Params["Key"] == latest_key + return "https://test-bucket.s3.amazonaws.com/latest" + + monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) + + url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_302_FOUND + assert resp["Location"].endswith("/latest") + + def test_compliance_local_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """The local branch serves the most recently modified matching file.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + + with tempfile.TemporaryDirectory() as tmp: + comp_dir = Path(tmp) / "reports" / "compliance" + comp_dir.mkdir(parents=True, exist_ok=True) + + old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv" + old_file.write_bytes(b"old") + latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv" + latest_file.write_bytes(b"latest") + # Make `latest_file` newer regardless of creation order. + os.utime(old_file, (1_700_000_000, 1_700_000_000)) + os.utime(latest_file, (1_700_000_100, 1_700_000_100)) + + scan.output_location = str(Path(tmp) / "reports" / "scan.zip") + scan.save() + + monkeypatch.setattr( + glob, + "glob", + lambda p: [str(old_file), str(latest_file)], + ) + + url = reverse( + "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"} + ) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_200_OK + assert resp.content == b"latest" + assert resp["Content-Disposition"].endswith( + f'filename="{latest_file.name}"' + ) + def test_compliance_s3_not_found( self, authenticated_client, scans_fixture, monkeypatch ): @@ -4294,18 +4380,24 @@ class TestScanViewSet: assert cd.startswith('attachment; filename="') assert cd.endswith(f'filename="{fname.name}"') - @patch("api.v1.views.Task.objects.get") @patch("api.v1.views.TaskSerializer") def test__get_task_status_returns_none_if_task_not_executing( - self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture + self, mock_task_serializer, authenticated_client, scans_fixture ): scan = scans_fixture[0] scan.state = StateChoices.COMPLETED scan.output_location = "dummy" scan.save() - task = Task.objects.create(tenant_id=scan.tenant_id) - mock_task_get.return_value = task + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) mock_task_serializer.return_value.data = { "id": str(task.id), "state": StateChoices.COMPLETED, @@ -4326,6 +4418,7 @@ class TestScanViewSet: scan.save() task_result = TaskResult.objects.create( + task_id=str(uuid4()), task_name="scan-report", task_kwargs={"scan_id": str(scan.id)}, ) @@ -4346,6 +4439,51 @@ class TestScanViewSet: assert response.status_code == status.HTTP_202_ACCEPTED assert response.data["id"] == str(task.id) + @patch("api.v1.views.TaskSerializer") + def test__get_task_status_returns_latest_task( + self, mock_task_serializer, authenticated_client, scans_fixture + ): + """With several scan-report tasks for the scan, the most recent is used.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "dummy" + scan.save() + + old_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + new_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + # `inserted_at` is `auto_now_add`, and within the test transaction the DB + # `now()` is constant, so force distinct timestamps to make order_by stable. + base = datetime(2024, 1, 1, tzinfo=timezone.utc) + Task.objects.filter(pk=old_task.pk).update(inserted_at=base) + Task.objects.filter(pk=new_task.pk).update( + inserted_at=base + timedelta(hours=1) + ) + + mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace( + data={"id": str(instance.id), "state": StateChoices.EXECUTING} + ) + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert str(new_task.id) in response["Content-Location"] + assert str(old_task.id) not in response["Content-Location"] + @patch("api.v1.views.get_s3_client") @patch("api.v1.views.sentry_sdk.capture_exception") def test_compliance_list_objects_client_error( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 067a0403a3..bf5cdcc54b 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -2059,12 +2059,17 @@ class ScanViewSet(BaseRLSViewSet): if scan_instance.state == StateChoices.EXECUTING and scan_instance.task: task = scan_instance.task else: - try: - task = Task.objects.get( + # A scan can have several `scan-report` tasks (e.g. re-runs); take the + # most recent one. `.first()` also avoids `MultipleObjectsReturned`. + task = ( + Task.objects.filter( task_runner_task__task_name="scan-report", task_runner_task__task_kwargs__contains=str(scan_instance.id), ) - except Task.DoesNotExist: + .order_by("-inserted_at") + .first() + ) + if task is None: return None self.response_serializer_class = TaskSerializer @@ -2139,27 +2144,32 @@ class ScanViewSet(BaseRLSViewSet): status=status.HTTP_502_BAD_GATEWAY, ) contents = resp.get("Contents", []) - keys = [] + matches = [] for obj in contents: key = obj["Key"] key_basename = os.path.basename(key) if any(ch in suffix for ch in ("*", "?", "[")): if fnmatch.fnmatch(key_basename, suffix): - keys.append(key) + matches.append(obj) elif key_basename == suffix: - keys.append(key) + matches.append(obj) elif key.endswith(suffix): # Backward compatibility if suffix already includes directories - keys.append(key) - if not keys: + matches.append(obj) + if not matches: return Response( { "detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'." }, status=status.HTTP_404_NOT_FOUND, ) - # path_pattern here is prefix, but in compliance we build correct suffix check before - key = keys[0] + # Return the most recently modified match (latest report) when + # several files share the prefix/suffix. `list_objects_v2` always + # returns `LastModified`; the fallback keeps ordering deterministic + # if it is ever absent. + key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[ + "Key" + ] else: # path_pattern is exact key; HEAD before presigning to preserve the 404 contract. key = path_pattern @@ -2209,7 +2219,9 @@ class ScanViewSet(BaseRLSViewSet): }, status=status.HTTP_404_NOT_FOUND, ) - filepath = files[0] + # Return the most recently modified match (latest report) when the + # pattern resolves to several files. + filepath = max(files, key=os.path.getmtime) with open(filepath, "rb") as f: content = f.read() filename = os.path.basename(filepath) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 0fda2999a9..b35b68893d 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -467,8 +467,31 @@ def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) +def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path: + """Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id}).""" + return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id) + + +class ScanReportRLSTask(RLSTask): + """ + RLS task that removes the scan's tmp output directory when the task fails. + + Covers failures both inside and outside the task body (e.g. ENOSPC mid-write, + or setup errors) so partial artifacts do not accumulate on the worker disk. + """ + + def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002 + del args # Required by Celery's Task.on_failure signature; not used. + tenant_id = kwargs.get("tenant_id") + scan_id = kwargs.get("scan_id") + + if tenant_id and scan_id: + logger.error(f"Scan report task {task_id} failed: {exc}") + rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True) + + @shared_task( - base=RLSTask, + base=ScanReportRLSTask, name="scan-report", queue="scan-reports", ) @@ -518,6 +541,9 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) + # Removed on success here and on failure by ScanReportRLSTask.on_failure, + # so partial artifacts do not accumulate and fill the disk (ENOSPC). + scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id) def get_writer(writer_map, name, factory, is_last): """ @@ -666,7 +692,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # TODO: We need to create a new periodic task to delete the output files # This task shouldn't be responsible for deleting the output files try: - rmtree(Path(compressed).parent, ignore_errors=True) + rmtree(scan_tmp_dir, ignore_errors=True) except Exception as e: logger.error(f"Error deleting output files: {e}") final_location, did_upload = upload_uri, True diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index f15bdf7192..7a52c27323 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -15,8 +15,10 @@ from tasks.jobs.lighthouse_providers import ( from tasks.tasks import ( DJANGO_TMP_OUTPUT_DIRECTORY, STALE_TMP_OUTPUT_MAX_AGE_HOURS, + ScanReportRLSTask, _cleanup_orphan_scheduled_scans, _perform_scan_complete_tasks, + _scan_tmp_output_directory, check_integrations_task, check_lighthouse_provider_connection_task, generate_outputs_task, @@ -771,6 +773,38 @@ class TestGenerateOutputs: mock_s3_task.assert_called_once() +class TestScanReportRLSTaskOnFailure: + def test_on_failure_removes_scan_tmp_directory(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={"tenant_id": "t-1", "scan_id": "s-1"}, + _einfo=None, + ) + + mock_rmtree.assert_called_once_with( + _scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True + ) + + def test_on_failure_skips_when_missing_kwargs(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={}, + _einfo=None, + ) + + mock_rmtree.assert_not_called() + + class TestScanCompleteTasks: @patch("tasks.tasks.aggregate_attack_surface_task.apply_async") @patch("tasks.tasks.chain") diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ea1707c71b..cbf3e84737 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.29.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + ## [5.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index 731d029284..53f27d0e1b 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -227,6 +227,10 @@ class OCSF(Output): json_output = finding.json(exclude_none=True, indent=4) self._file_descriptor.write(json_output) self._file_descriptor.write(",") + except OSError: + # I/O errors (e.g. ENOSPC) are not recoverable per finding: + # fail fast instead of logging once per finding. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -239,6 +243,10 @@ class OCSF(Output): self._file_descriptor.truncate() self._file_descriptor.write("]") self._file_descriptor.close() + except OSError: + # Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can + # fail fast instead of producing a corrupt output file. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index 16ff1ce317..f449fd49f7 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -2,8 +2,10 @@ import json from datetime import datetime, timezone from io import StringIO from typing import Optional +from unittest.mock import MagicMock from uuid import UUID +import pytest import requests from freezegun import freeze_time from mock import patch @@ -300,6 +302,36 @@ class TestOCSF: def test_batch_write_data_to_file_without_findings(self): assert not OCSF([])._file_descriptor + def test_batch_write_data_to_file_propagates_oserror(self): + """An I/O error (e.g. ENOSPC) while writing a finding must propagate + instead of being swallowed, so the caller can fail fast.""" + findings = [ + generate_finding_output( + status="FAIL", + severity="low", + muted=False, + region=AWS_REGION_EU_WEST_1, + timestamp=datetime.now(), + resource_details="resource_details", + resource_name="resource_name", + resource_uid="resource-id", + status_extended="status extended", + ) + ] + + output = OCSF(findings) + mock_file = MagicMock() + mock_file.closed = False + # Non-zero so the "[" prelude is skipped and the failure happens on the + # per-finding write, the exact path that hit ENOSPC in production. + mock_file.tell.return_value = 1 + mock_file.write.side_effect = OSError(28, "No space left on device") + output._file_descriptor = mock_file + + with pytest.raises(OSError) as excinfo: + output.batch_write_data_to_file() + assert excinfo.value.errno == 28 + def test_finding_output_cloud_pass_low_muted(self): finding_output = generate_finding_output( status="PASS", From 7f67eac1bff75cbb4ff9246abd56bbd184df9dac Mon Sep 17 00:00:00 2001 From: Davidm4r Date: Tue, 2 Jun 2026 13:19:21 +0200 Subject: [PATCH 003/129] perf(api): avoid N+1 query loading finding resource tags (#11420) Co-authored-by: Pepe Fagoaga --- api/CHANGELOG.md | 1 + api/src/backend/api/tests/test_views.py | 77 +++++++++++++++++++++++++ api/src/backend/api/v1/views.py | 10 ++++ 3 files changed, 88 insertions(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index fbc7f178ad..8f400fd012 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed +- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420) - Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) --- diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 9e0b4362a3..02a83a997c 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -24,9 +24,11 @@ from conftest import ( today_after_n_days, ) from django.conf import settings +from django.db import connection from django.db.models import Count from django.http import JsonResponse from django.test import RequestFactory +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status @@ -64,6 +66,7 @@ from api.models import ( ProviderSecret, Resource, ResourceFindingMapping, + ResourceTag, Role, RoleProviderGroupRelationship, SAMLConfiguration, @@ -7054,6 +7057,80 @@ class TestFindingViewSet: == findings_fixture[0].status ) + def test_findings_list_resource_tags_no_n_plus_one( + self, authenticated_client, findings_fixture + ): + """Listing findings must load every resource's tags in a constant + number of queries, no matter how many findings/resources are returned. + + This guards ``FindingViewSet._optimize_tags_loading`` against + regressions that would reintroduce one extra query per resource (the + N+1 the prefetch was added to remove). + """ + scan = findings_fixture[0].scan + tenant_id = findings_fixture[0].tenant_id + provider = scan.provider + + def _create_finding_with_tagged_resource(index): + resource = Resource.objects.create( + tenant_id=tenant_id, + provider=provider, + uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}", + name=f"N+1 Instance {index}", + region="us-east-1", + service="ec2", + type="prowler-test", + ) + resource.upsert_or_delete_tags( + [ + ResourceTag.objects.create( + tenant_id=tenant_id, + key=f"key-{index}", + value=f"value-{index}", + ) + ] + ) + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"n_plus_one_finding_{index}", + scan=scan, + status=Status.FAIL, + status_extended="n+1 status", + impact=Severity.medium, + severity=Severity.medium, + check_id="test_check_id", + check_metadata={"CheckId": "test_check_id", "servicename": "ec2"}, + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + params = {"filter[inserted_at]": TODAY, "include": "resources"} + + # Baseline: the two findings provided by the fixture. + with CaptureQueriesContext(connection) as baseline: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + + # Add more findings, each with its own resource carrying tags. + extra_findings = 5 + for index in range(extra_findings): + _create_finding_with_tagged_resource(index) + + with CaptureQueriesContext(connection) as scaled: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(findings_fixture) + extra_findings + + # The query count must not grow with the number of findings/resources. + assert len(scaled.captured_queries) == len(baseline.captured_queries), ( + "Resource tags are not being prefetched: " + f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} " + f"findings vs {len(scaled.captured_queries)} for " + f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression " + "in FindingViewSet._optimize_tags_loading." + ) + @pytest.mark.parametrize( "include_values, expected_resources", [ diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index bf5cdcc54b..4a09a04838 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -3761,6 +3761,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): return queryset return super().filter_queryset(queryset) + def _optimize_tags_loading(self, queryset): + """Prefetch resource tags to avoid N+1 queries when serializing findings""" + return queryset.prefetch_related( + Prefetch( + "resources__tags", + queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id), + to_attr="prefetched_tags", + ) + ) + def list(self, request, *args, **kwargs): filtered_queryset = self.filter_queryset(self.get_queryset()) return self.paginate_by_pk( From cf9beb82342887e46364bb524fba6a9f03272d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Tue, 2 Jun 2026 14:00:17 +0200 Subject: [PATCH 004/129] feat(api): recover orphaned background tasks and make task re-runs idempotent (#11416) --- api/CHANGELOG.md | 18 + api/docker-entrypoint.sh | 8 +- api/docs/orphan-task-recovery.md | 86 ++++ .../commands/reconcile_orphan_tasks.py | 49 +++ .../migrations/0094_scan_recovery_count.py | 17 + ...95_reconcile_orphan_tasks_periodic_task.py | 49 +++ .../api/migrations/0096_jiraissuedispatch.py | 64 +++ api/src/backend/api/models.py | 32 ++ api/src/backend/config/celery.py | 55 +++ .../tasks/jobs/attack_paths/cleanup.py | 30 +- api/src/backend/tasks/jobs/deletion.py | 9 + api/src/backend/tasks/jobs/integrations.py | 151 ++++--- api/src/backend/tasks/jobs/orphan_recovery.py | 397 ++++++++++++++++++ api/src/backend/tasks/jobs/scan.py | 28 +- api/src/backend/tasks/tasks.py | 7 + api/src/backend/tasks/tests/test_deletion.py | 40 +- .../backend/tasks/tests/test_integrations.py | 179 +++++++- .../tasks/tests/test_orphan_recovery.py | 372 ++++++++++++++++ api/src/backend/tasks/tests/test_scan.py | 184 ++++++++ api/src/backend/tasks/tests/test_tasks.py | 33 ++ docker-compose-dev.yml | 2 + docker-compose.yml | 2 + 22 files changed, 1722 insertions(+), 90 deletions(-) create mode 100644 api/docs/orphan-task-recovery.md create mode 100644 api/src/backend/api/management/commands/reconcile_orphan_tasks.py create mode 100644 api/src/backend/api/migrations/0094_scan_recovery_count.py create mode 100644 api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py create mode 100644 api/src/backend/api/migrations/0096_jiraissuedispatch.py create mode 100644 api/src/backend/tasks/jobs/orphan_recovery.py create mode 100644 api/src/backend/tasks/tests/test_orphan_recovery.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8f400fd012..bf95d3c569 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.31.0] (Prowler v5.30.0) + +### 🚀 Added + +- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) + +### 🔄 Changed + +- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) + +### 🐞 Fixed + +- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) + +--- + ## [1.30.1] (Prowler v5.29.1) ### 🐞 Fixed diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 4535fb34e2..e6313f459a 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -22,12 +22,12 @@ apply_fixtures() { start_dev_server() { echo "Starting the development server..." - uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" + exec uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" } start_prod_server() { echo "Starting the Gunicorn server..." - uv run gunicorn -c config/guniconf.py config.wsgi:application + exec uv run gunicorn -c config/guniconf.py config.wsgi:application } resolve_worker_hostname() { @@ -47,7 +47,7 @@ resolve_worker_hostname() { start_worker() { echo "Starting the worker..." - uv run python -m celery -A config.celery worker \ + exec uv run python -m celery -A config.celery worker \ -n "$(resolve_worker_hostname)" \ -l "${DJANGO_LOGGING_LEVEL:-info}" \ -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ @@ -56,7 +56,7 @@ start_worker() { start_worker_beat() { echo "Starting the worker-beat..." - uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler + exec uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler } manage_db_partitions() { diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md new file mode 100644 index 0000000000..38b1546bae --- /dev/null +++ b/api/docs/orphan-task-recovery.md @@ -0,0 +1,86 @@ +# Orphan Celery task recovery + +When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the +task it was running can be left non-terminal forever: the `Scan` stays `EXECUTING`, +the `TaskResult` stays `STARTED`, and nothing re-runs it. This page describes the +mechanisms that detect and recover allowlisted idempotent orphans so users never +see a stuck scan and pending-task alerts do not fire. + +## How recovery works + +1. **Durable delivery.** The broker is configured so a task message is acknowledged + only after the task finishes (`task_acks_late`), one task is reserved at a time + (`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task + (`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown + window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work + before it is force-killed. + +2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of + minutes (a `django_celery_beat` periodic task created by migration). For each + in-flight task result with an allowlisted idempotent task name, it pings the + worker recorded on the task's `TaskResult`: + - worker responds -> the task is still running, leave it alone; + - worker is gone (and the scan started before a short grace window) -> it is a + real orphan: the stale task is revoked and marked terminal (clearing the + pending/started alert), and the scan is re-enqueued from scratch. + + The re-run is safe because only tasks with proven idempotency are allowlisted. + Scan persistence, for example, clears the scan's prior findings and materialized + summary/compliance rows before re-writing them. Jira sends are allowlisted too: + each finding is reserved in a dispatch table before the external call, so a re-run + skips already-ticketed findings (the worst case is one finding missed if a worker + is hard-killed mid-send, never a duplicate issue). Other external side effects stay + terminal: the S3 upload rebuilds from worker-local files that do not survive a + crash, and report/Security Hub recovery is out of scope. + +3. **Recovery cap.** Each automatic re-enqueue increments `Scan.recovery_count`. + After `--max-attempts` recoveries (default 3) the scan is marked `FAILED` instead + of re-enqueued, so a task that repeatedly kills its worker cannot loop forever. + +A Postgres advisory lock ensures that, even with multiple API/worker replicas, only +one reconciliation runs at a time; the others no-op. + +## On-demand command + +The same logic is available as a management command, useful right after a deploy or +for manual intervention: + +```bash +python manage.py reconcile_orphan_tasks # recover now +python manage.py reconcile_orphan_tasks --dry-run # report orphans, change nothing +python manage.py reconcile_orphan_tasks --grace-minutes 5 --max-attempts 3 +``` + +## Configuration + +All settings have safe defaults; override via environment variables. + +| Env var | Default | Purpose | +| --- | --- | --- | +| `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. | +| `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. | +| `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. | +| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | +| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. | +| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. | + +`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`. + +## Deployment requirement + +Two conditions must both hold for the soft shutdown to actually drain work: + +1. **The worker must receive `SIGTERM`.** The container entrypoint `exec`s the + Celery process so it runs as PID 1; otherwise `SIGTERM` from `docker stop`/ECS + hits the entrypoint shell, never reaches Celery, and the worker is hard-killed + (SIGKILL) at the grace deadline without draining. Custom entrypoints must + preserve the `exec`. +2. **The orchestrator must give the worker enough time** before force-killing it. + Set the stop grace period to exceed `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` + plus a margin: + - **docker-compose:** `stop_grace_period` on the worker services (set to `120s`). + - **AWS ECS:** the worker container `stopTimeout` (configured in the deployment + repository). + +If either condition is missing, long tasks are still recovered by the watchdog, +but they are cut mid-run on every deploy instead of draining. diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py new file mode 100644 index 0000000000..cdfe6b3fda --- /dev/null +++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py @@ -0,0 +1,49 @@ +from django.core.management.base import BaseCommand + +from tasks.jobs.orphan_recovery import reconcile_orphans + + +class Command(BaseCommand): + help = ( + "Recover orphaned allowlisted Celery tasks whose worker is gone and mark " + "other stale task results terminal. Single-flight via a Postgres advisory lock." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--grace-minutes", + type=int, + default=2, + help="Skip tasks started within this window (worker may still register).", + ) + parser.add_argument( + "--max-attempts", + type=int, + default=3, + help="Give up re-running a task after this many recovery attempts (scans are marked FAILED).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Detect and report orphans without revoking or re-enqueuing.", + ) + + def handle(self, *args, **options): + result = reconcile_orphans( + grace_minutes=options["grace_minutes"], + max_attempts=options["max_attempts"], + dry_run=options["dry_run"], + ) + + if not result.get("acquired"): + self.stdout.write("Reconcile skipped: another run holds the lock.") + return + + self.stdout.write( + self.style.SUCCESS( + "Orphan reconcile complete: " + f"recovered={len(result.get('recovered', []))} " + f"failed={len(result.get('failed', []))} " + f"skipped(in-flight)={len(result.get('skipped', []))}" + ) + ) diff --git a/api/src/backend/api/migrations/0094_scan_recovery_count.py b/api/src/backend/api/migrations/0094_scan_recovery_count.py new file mode 100644 index 0000000000..01f7af42df --- /dev/null +++ b/api/src/backend/api/migrations/0094_scan_recovery_count.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.15 on 2026-05-30 17:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0093_okta_provider"), + ] + + operations = [ + migrations.AddField( + model_name="scan", + name="recovery_count", + field=models.IntegerField(default=0), + ), + ] diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py new file mode 100644 index 0000000000..ab511a11b1 --- /dev/null +++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +TASK_NAME = "reconcile-orphan-tasks" +INTERVAL_MINUTES = 2 + + +def create_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=INTERVAL_MINUTES, + period="minutes", + ) + + PeriodicTask.objects.update_or_create( + name=TASK_NAME, + defaults={ + "task": TASK_NAME, + "interval": schedule, + "enabled": True, + }, + ) + + +def delete_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + PeriodicTask.objects.filter(name=TASK_NAME).delete() + + # Clean up the schedule if no other task references it + IntervalSchedule.objects.filter( + every=INTERVAL_MINUTES, + period="minutes", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0094_scan_recovery_count"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/migrations/0096_jiraissuedispatch.py b/api/src/backend/api/migrations/0096_jiraissuedispatch.py new file mode 100644 index 0000000000..f5a1a9d9a0 --- /dev/null +++ b/api/src/backend/api/migrations/0096_jiraissuedispatch.py @@ -0,0 +1,64 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0095_reconcile_orphan_tasks_periodic_task"), + ] + + operations = [ + migrations.CreateModel( + name="JiraIssueDispatch", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("finding_id", models.UUIDField()), + ( + "integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="jira_dispatches", + to="api.integration", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "jira_issue_dispatches", + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="jiraissuedispatch", + constraint=models.UniqueConstraint( + fields=("tenant_id", "integration_id", "finding_id"), + name="unique_jira_issue_dispatch", + ), + ), + migrations.AddConstraint( + model_name="jiraissuedispatch", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_jiraissuedispatch", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 3d9a26698e..8adbc2cf9e 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -666,6 +666,9 @@ class Scan(RowLevelSecurityProtectedModel): state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE) unique_resource_count = models.IntegerField(default=0) progress = models.IntegerField(default=0) + # Incremented by the scan-specific orphan-recovery path each time this scan is + # re-pointed to a fresh task; for observability (the retry cap is a Valkey counter). + recovery_count = models.IntegerField(default=0) scanner_args = models.JSONField(default=dict) duration = models.IntegerField(null=True, blank=True) scheduled_at = models.DateTimeField(null=True, blank=True) @@ -1998,6 +2001,35 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel): ] +class JiraIssueDispatch(RowLevelSecurityProtectedModel): + """Tracks findings already sent to a Jira integration. + + Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with + an existing dispatch row are skipped, so no duplicate issues are created. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + integration = models.ForeignKey( + Integration, on_delete=models.CASCADE, related_name="jira_dispatches" + ) + finding_id = models.UUIDField() + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "jira_issue_dispatches" + constraints = [ + models.UniqueConstraint( + fields=["tenant_id", "integration_id", "finding_id"], + name="unique_jira_issue_dispatch", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class SAMLToken(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index c46c8a426c..5d246395a5 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -26,6 +26,61 @@ celery_app.conf.result_backend_transport_options = { } celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT +# Durable delivery: keep the message until the task finishes, so a worker killed +# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a +# time so a crash exposes at most one extra reserved message. +celery_app.conf.task_acks_late = True +celery_app.conf.task_reject_on_worker_lost = True +celery_app.conf.worker_prefetch_multiplier = env.int( + "DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1 +) +# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before +# it is forcefully killed (Celery 5.5+ soft shutdown). +celery_app.conf.worker_soft_shutdown_timeout = env.int( + "DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60 +) +# Bound execution so a blocked task cannot pin a worker forever. Connection +# checks get a tight limit; scans and provider/tenant deletions can legitimately +# run for more than a day on large tenants, so they get a much higher cap. +# The default for every other task is set as the global limit, not as a "*" +# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in +# task_annotations would silently overwrite every specific limit defined below. +_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60) +_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600 +) +_LONG_TASK_HARD_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60 +) +_LONG_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600 +) +celery_app.conf.task_time_limit = _TASK_HARD_LIMIT +celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT +celery_app.conf.task_annotations = { + **{ + name: {"soft_time_limit": 60, "time_limit": 120} + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ) + }, + **{ + name: { + "soft_time_limit": _LONG_TASK_SOFT_LIMIT, + "time_limit": _LONG_TASK_HARD_LIMIT, + } + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ) + }, +} + celery_app.autodiscover_tasks(["api"]) diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 65ba583a3e..fa7670afaa 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta, timezone -from celery import current_app, states +from celery import states from celery.utils.log import get_task_logger from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES from tasks.jobs.attack_paths.db_utils import ( _mark_scan_finished, recover_graph_data_ready, ) +from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive +from tasks.jobs.orphan_recovery import revoke_task as _revoke_task from api.attack_paths import database as graph_database from api.db_router import MainRouter @@ -150,32 +152,6 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: return cleaned_up -def _is_worker_alive(worker: str) -> bool: - """Ping a specific Celery worker. Returns `True` if it responds or on error.""" - try: - response = current_app.control.inspect(destination=[worker], timeout=1.0).ping() - return response is not None and worker in response - except Exception: - logger.exception(f"Failed to ping worker {worker}, treating as alive") - return True - - -def _revoke_task(task_result, terminate: bool = True) -> None: - """Revoke a Celery task. Non-fatal on failure. - - `terminate=True` SIGTERMs the worker if the task is mid-execution; use - for EXECUTING cleanup. `terminate=False` only marks the task id revoked - across workers, so any worker pulling the queued message discards it; - use for SCHEDULED cleanup where the task hasn't run yet. - """ - try: - kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {} - current_app.control.revoke(task_result.task_id, **kwargs) - logger.info(f"Revoked task {task_result.task_id}") - except Exception: - logger.exception(f"Failed to revoke task {task_result.task_id}") - - def _cleanup_scan(scan, task_result, reason: str) -> bool: """ Clean up a single stale `AttackPathsScan`: diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index f9ead01897..7540f72c7e 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -11,6 +11,7 @@ from api.db_utils import batch_delete, rls_transaction from api.models import ( AttackPathsScan, Finding, + JiraIssueDispatch, Provider, ProviderComplianceScore, Resource, @@ -80,6 +81,14 @@ def delete_provider(tenant_id: str, pk: str): deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), + ( + "Jira Issue Dispatches", + JiraIssueDispatch.objects.filter( + finding_id__in=Finding.all_objects.filter( + scan__provider=instance + ).values_list("id", flat=True) + ), + ), ("Findings", Finding.all_objects.filter(scan__provider=instance)), ("Resources", Resource.all_objects.filter(provider=instance)), ("Scans", Scan.all_objects.filter(provider=instance)), diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 5ca94057da..55c1205169 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -9,7 +9,7 @@ from tasks.utils import batched from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction -from api.models import Finding, Integration, Provider +from api.models import Finding, Integration, JiraIssueDispatch, Provider from api.utils import initialize_prowler_integration, initialize_prowler_provider from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.generic.generic import GenericCompliance @@ -482,66 +482,115 @@ def send_findings_to_jira( with rls_transaction(tenant_id): integration = Integration.objects.get(id=integration_id) jira_integration = initialize_prowler_integration(integration) + # Idempotency: findings already ticketed for this integration must not be + # sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues + already_sent = { + str(fid) + for fid in JiraIssueDispatch.objects.filter( + integration_id=integration_id, finding_id__in=finding_ids + ).values_list("finding_id", flat=True) + } num_tickets_created = 0 + skipped_count = 0 for finding_id in finding_ids: + if str(finding_id) in already_sent: + skipped_count += 1 + continue + + # Reserve the finding BEFORE the external call. The unique constraint on + # (tenant, integration, finding) makes the dispatch row the single source of + # truth, so a concurrent run or a retry that raced past the bulk pre-check + # cannot create a duplicate issue: created=False means another run already + # claimed it. The reservation is released below if the send does not succeed. with rls_transaction(tenant_id): - finding_instance = ( - Finding.all_objects.select_related("scan__provider") - .prefetch_related("resources") - .get(id=finding_id) + _, created = JiraIssueDispatch.objects.get_or_create( + tenant_id=tenant_id, + integration_id=integration_id, + finding_id=finding_id, ) + if not created: + skipped_count += 1 + continue - # Extract resource information - resource = ( - finding_instance.resources.first() - if finding_instance.resources.exists() - else None - ) - resource_uid = resource.uid if resource else "" - resource_name = resource.name if resource else "" - resource_tags = {} - if resource and hasattr(resource, "tags"): - resource_tags = resource.get_tags(tenant_id) + sent = False + try: + with rls_transaction(tenant_id): + finding_instance = ( + Finding.all_objects.select_related("scan__provider") + .prefetch_related("resources") + .get(id=finding_id) + ) - # Get region - region = resource.region if resource and resource.region else "" + # Extract resource information + resource = ( + finding_instance.resources.first() + if finding_instance.resources.exists() + else None + ) + resource_uid = resource.uid if resource else "" + resource_name = resource.name if resource else "" + resource_tags = {} + if resource and hasattr(resource, "tags"): + resource_tags = resource.get_tags(tenant_id) - # Extract remediation information from check_metadata - check_metadata = finding_instance.check_metadata - remediation = check_metadata.get("remediation", {}) - recommendation = remediation.get("recommendation", {}) - remediation_code = remediation.get("code", {}) + # Get region + region = resource.region if resource and resource.region else "" - # Send the individual finding to Jira - result = jira_integration.send_finding( - check_id=finding_instance.check_id, - check_title=check_metadata.get("checktitle", ""), - severity=finding_instance.severity, - status=finding_instance.status, - status_extended=finding_instance.status_extended or "", - provider=finding_instance.scan.provider.provider, - region=region, - resource_uid=resource_uid, - resource_name=resource_name, - risk=check_metadata.get("risk", ""), - recommendation_text=recommendation.get("text", ""), - recommendation_url=recommendation.get("url", ""), - remediation_code_native_iac=remediation_code.get("nativeiac", ""), - remediation_code_terraform=remediation_code.get("terraform", ""), - remediation_code_cli=remediation_code.get("cli", ""), - remediation_code_other=remediation_code.get("other", ""), - resource_tags=resource_tags, - compliance=finding_instance.compliance or {}, - project_key=project_key, - issue_type=issue_type, - ) - if result: - num_tickets_created += 1 - else: - logger.error(f"Failed to send finding {finding_id} to Jira") + # Extract remediation information from check_metadata + check_metadata = finding_instance.check_metadata + remediation = check_metadata.get("remediation", {}) + recommendation = remediation.get("recommendation", {}) + remediation_code = remediation.get("code", {}) + + # Send the individual finding to Jira + sent = bool( + jira_integration.send_finding( + check_id=finding_instance.check_id, + check_title=check_metadata.get("checktitle", ""), + severity=finding_instance.severity, + status=finding_instance.status, + status_extended=finding_instance.status_extended or "", + provider=finding_instance.scan.provider.provider, + region=region, + resource_uid=resource_uid, + resource_name=resource_name, + risk=check_metadata.get("risk", ""), + recommendation_text=recommendation.get("text", ""), + recommendation_url=recommendation.get("url", ""), + remediation_code_native_iac=remediation_code.get( + "nativeiac", "" + ), + remediation_code_terraform=remediation_code.get( + "terraform", "" + ), + remediation_code_cli=remediation_code.get("cli", ""), + remediation_code_other=remediation_code.get("other", ""), + resource_tags=resource_tags, + compliance=finding_instance.compliance or {}, + project_key=project_key, + issue_type=issue_type, + ) + ) + finally: + if not sent: + # Release the reservation so a later run can retry this finding: it + # was not ticketed (send failed or raised), so the row must not block + # a future legitimate send. + with rls_transaction(tenant_id): + JiraIssueDispatch.objects.filter( + tenant_id=tenant_id, + integration_id=integration_id, + finding_id=finding_id, + ).delete() + + if sent: + num_tickets_created += 1 + else: + logger.error(f"Failed to send finding {finding_id} to Jira") return { "created_count": num_tickets_created, - "failed_count": len(finding_ids) - num_tickets_created, + "failed_count": len(finding_ids) - num_tickets_created - skipped_count, + "skipped_count": skipped_count, } diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py new file mode 100644 index 0000000000..d884c3fc8b --- /dev/null +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -0,0 +1,397 @@ +"""Detect and recover orphaned Celery tasks. + +A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the +worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan +from a still-running task by pinging the worker recorded on its `TaskResult`: + +- worker responds -> the task is in flight, leave it alone (never double-run); +- worker is gone -> real orphan: mark the stale result terminal (so pending/started + alerts clear), then re-enqueue the task from its stored name + kwargs. + +This recovers only allowlisted tasks with local, proven idempotency. Celery's +`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once +the task starts, but external side-effect tasks are failed instead of blindly +re-run. A small recovery cap stops a task that repeatedly kills its worker from +looping forever. + +This is the shared engine behind both the periodic Beat watchdog and the +`reconcile_orphan_tasks` management command. +""" + +import ast +import json +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from celery import current_app, states +from celery.utils.log import get_task_logger +from django.db import connections + +logger = get_task_logger(__name__) + +# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation +# runs at a time across replicas / the watchdog / the command. +ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow" + +# Non-terminal states that mean "a worker had this and may have died with it". +IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED) + +# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row, +# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would +# hit its "a scan is already executing" guard and no-op, leaving the scan stuck. +_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled") + +# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and +# rewrite their own rows. integration-jira is safe too: each finding is reserved in +# JiraIssueDispatch before the external call, so a re-run skips already-ticketed +# findings (worst case one finding missed on a mid-send crash, never a duplicate). +# Other external side effects stay terminal: integration-s3 rebuilds its upload from +# worker-local files that do not survive a crash, and report/Security Hub recovery is +# out of scope. +REENQUEUEABLE_TASKS = { + *_SCAN_TASKS, + "provider-deletion", + "tenant-deletion", + "scan-summary", + "scan-compliance-overviews", + "scan-provider-compliance-scores", + "scan-daily-severity", + "scan-finding-group-summaries", + "scan-reset-ephemeral-resources", + "integration-jira", +} + +# Tasks excluded from generic recovery: attack-paths scans are handled by their own +# stale-cleanup (which also drops the temp Neo4j db), and the maintenance tasks must +# not self-recover (they run again on their own schedule). +_SKIP_RECOVERY = { + "attack-paths-scan-perform", + "attack-paths-cleanup-stale-scans", + "reconcile-orphan-tasks", +} + + +@contextmanager +def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"): + """Yield True if this session won a Postgres advisory lock, else False. + + Non-blocking: losers get False and should no-op. The lock is released on + exit (and implicitly if the session dies). + """ + with connections[using].cursor() as cursor: + cursor.execute("SELECT pg_try_advisory_lock(%s)", [key]) + acquired = bool(cursor.fetchone()[0]) + try: + yield acquired + finally: + if acquired: + cursor.execute("SELECT pg_advisory_unlock(%s)", [key]) + + +def is_worker_alive(worker: str, timeout: float = 1.0) -> bool: + """Ping a specific Celery worker. Returns True if it responds, or on error. + + Erring on the side of "alive" means an unreachable control bus never causes + a still-running task to be re-enqueued. + """ + try: + response = current_app.control.inspect( + destination=[worker], timeout=timeout + ).ping() + return response is not None and worker in response + except Exception: + logger.exception(f"Failed to ping worker {worker}, treating as alive") + return True + + +def revoke_task(task_result, terminate: bool = True) -> None: + """Revoke a Celery task by its TaskResult. Non-fatal on failure. + + terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False + only marks the id revoked so any worker pulling the queued message discards it + (use before re-enqueuing, so a later broker redelivery of the stale message is + dropped). + """ + try: + kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {} + current_app.control.revoke(task_result.task_id, **kwargs) + logger.info(f"Revoked task {task_result.task_id}") + except Exception: + logger.exception(f"Failed to revoke task {task_result.task_id}") + + +def _decode_celery_field(value, default): + """Decode django-celery-results' stored task_args/task_kwargs to a Python object. + + The backend stores them as a (sometimes double-encoded) repr/JSON string. An + empty or missing field returns ``default``; a non-empty value that cannot be + decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with + the wrong arguments. + """ + obj = value + for _ in range(2): # values can be double-encoded (a string holding a repr) + if not isinstance(obj, str): + break + text = obj.strip() + if not text: + return default + parsed = None + for parser in (ast.literal_eval, json.loads): + try: + parsed = parser(text) + break + except (ValueError, SyntaxError, TypeError): + continue + if parsed is None: + raise ValueError(f"undecodable celery field: {text[:120]!r}") + obj = parsed + return default if obj is None else obj + + +def reconcile_orphans( + grace_minutes: int = 2, + max_attempts: int = 3, + window_hours: int = 6, + dry_run: bool = False, +) -> dict: + """Run the full orphan sweep under a single-flight advisory lock. + + Recovers any orphaned in-flight task and delegates attack-paths scans that + never reached a worker to their existing stale-cleanup. Returns a summary; + a no-op (lock not won) is reported too. + """ + with advisory_lock() as acquired: + if not acquired: + logger.info("Orphan reconcile skipped: another run holds the lock") + return {"acquired": False} + + # Populate the task registry so we can re-enqueue any task by name. + import tasks.tasks # noqa: F401 + + result = _reconcile_task_results( + grace_minutes=grace_minutes, + max_attempts=max_attempts, + window_hours=window_hours, + dry_run=dry_run, + ) + + if not dry_run: + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + result["attack_paths"] = cleanup_stale_attack_paths_scans() + + return {"acquired": True, **result} + + +def _reconcile_task_results( + grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool +) -> dict: + from django_celery_results.models import TaskResult + + cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=grace_minutes) + candidates = list( + TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff) + .exclude(worker__isnull=True) + .exclude(worker="") + .exclude(task_name__in=_SKIP_RECOVERY) + ) + + # Ping each distinct worker at most once. + worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}} + + recovered, failed, skipped = [], [], [] + for task_result in candidates: + if worker_alive.get(task_result.worker, True): + skipped.append(task_result.task_id) # in flight, do not double-run + continue + if dry_run: + recovered.append(task_result.task_id) + continue + outcome = _recover_task(task_result, max_attempts, window_hours) + (recovered if outcome == "recovered" else failed).append(task_result.task_id) + + logger.info( + "Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d", + len(recovered), + len(failed), + len(skipped), + ) + return {"recovered": recovered, "failed": failed, "skipped": skipped} + + +def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int: + """Increment and return the recovery count for this (task, kwargs) within the + window. Backed by Valkey so it survives result-row churn (a worker processing + the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the + broker being unreachable means nothing is running anyway). + """ + import hashlib + + from django.conf import settings + + try: + import redis + + client = redis.from_url(settings.CELERY_BROKER_URL) + signature = f"{name}|{kwargs_repr}".encode() + key = ( + "orphan-recovery:" + + hashlib.sha1(signature, usedforsecurity=False).hexdigest() + ) + count = client.incr(key) + if count == 1: + client.expire(key, max(1, window_hours) * 3600) + return int(count) + except Exception: + logger.exception("Recovery-attempt counter unavailable; allowing recovery") + return 1 + + +def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: + """Recover one orphaned task. Returns 'recovered' or 'failed'.""" + # Capture name/args/kwargs now: revoking can let a worker blank the row. + name = task_result.task_name + args_repr = task_result.task_args + kwargs_repr = task_result.task_kwargs + now = datetime.now(tz=timezone.utc) + + # Drop any future broker redelivery of the stale message. + revoke_task(task_result, terminate=False) + + # Mark the stale result terminal so "pending/started forever" alerts clear. + task_result.status = states.REVOKED + task_result.date_done = now + task_result.save(update_fields=["status", "date_done"]) + + attempt = _recovery_attempt_count(name, kwargs_repr, window_hours) + if name not in REENQUEUEABLE_TASKS or attempt > max_attempts: + reason = ( + f"{name} is not allowlisted for auto recovery" + if name not in REENQUEUEABLE_TASKS + else f"recovery cap reached ({attempt}/{max_attempts})" + ) + _fail_domain_row(task_result.task_id, name, now) + logger.warning( + "Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason + ) + return "failed" + + # Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the + # scheduled-scan "already executing" guard cannot turn recovery into a no-op. + # Falls through to the generic path only if no scan is linked yet (e.g. a + # scheduled task that died before creating one), where re-running it creates one. + if name in _SCAN_TASKS: + scan = _scan_for_task(task_result.task_id) + if scan is not None: + if not _reenqueue_scan(task_result.task_id, scan): + return "failed" + logger.info( + "Re-enqueued orphaned scan %s (was task %s)", + scan.id, + task_result.task_id, + ) + return "recovered" + + task_obj = current_app.tasks.get(name) + if task_obj is None: + logger.error( + "Orphan %s: task %s not registered, cannot re-enqueue", + task_result.task_id, + name, + ) + return "failed" + + try: + args = _decode_celery_field(args_repr, []) + kwargs = _decode_celery_field(kwargs_repr, {}) + except ValueError: + logger.error( + "Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing", + task_result.task_id, + name, + ) + _fail_domain_row(task_result.task_id, name, now) + return "failed" + new_task_id = str(uuid4()) + task_obj.apply_async( + args=list(args) if isinstance(args, (list, tuple)) else [], + kwargs=kwargs if isinstance(kwargs, dict) else {}, + task_id=new_task_id, + ) + logger.info( + "Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id + ) + return "recovered" + + +def _scan_for_task(task_id: str): + """Return the Scan linked to a Celery task id, or None (read across tenants).""" + from api.db_router import MainRouter + from api.models import Scan + + return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first() + + +def _reenqueue_scan(old_task_id: str, scan) -> bool: + """Re-run an orphaned scan via scan-perform on the existing row. + + Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the + Scan before enqueuing, so the FK is valid and a worker can never outrun the DB. + The relink is conditional on the scan still pointing at the old task, so a stale + orphan can never clobber a newer linkage. + """ + from django_celery_results.models import TaskResult + + from api.db_utils import rls_transaction + from api.models import Scan + from api.models import Task as APITask + from tasks.tasks import perform_scan_task + + tenant_id = str(scan.tenant_id) + new_task_id = str(uuid4()) + with rls_transaction(tenant_id): + locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first() + if locked_scan is None or str(locked_scan.task_id) != old_task_id: + logger.info( + "Scan %s no longer points at task %s; skipping recovery re-enqueue", + scan.id, + old_task_id, + ) + return False + task_result_new, _ = TaskResult.objects.get_or_create( + task_id=new_task_id, + defaults={"status": states.PENDING, "task_name": "scan-perform"}, + ) + APITask.objects.update_or_create( + id=new_task_id, + tenant_id=tenant_id, + defaults={"task_runner_task": task_result_new}, + ) + locked_scan.task_id = new_task_id + locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1 + locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"]) + + perform_scan_task.apply_async( + kwargs={ + "tenant_id": tenant_id, + "scan_id": str(scan.id), + "provider_id": str(scan.provider_id), + }, + task_id=new_task_id, + ) + return True + + +def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None: + """Mark a scan terminal when its task is capped/denylisted instead of re-run.""" + from api.db_utils import rls_transaction + from api.models import Scan, StateChoices + + if name in _SCAN_TASKS: + scan = _scan_for_task(old_task_id) + if scan is not None: + with rls_transaction(str(scan.tenant_id)): + Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update( + state=StateChoices.FAILED, completed_at=now + ) diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index a772173a7f..298a2b225a 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -118,6 +118,19 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { _ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {} +def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None: + """Remove rows derived from a previous execution of this scan.""" + with rls_transaction(tenant_id): + Finding.all_objects.filter(scan_id=scan_id).delete() + ResourceScanSummary.objects.filter(scan_id=scan_id).delete() + ScanCategorySummary.objects.filter(scan_id=scan_id).delete() + ScanGroupSummary.objects.filter(scan_id=scan_id).delete() + ScanSummary.objects.filter(scan_id=scan_id).delete() + AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete() + ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() + ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() + + def aggregate_category_counts( categories: list[str], severity: str, @@ -476,9 +489,13 @@ def _create_compliance_summaries( ) ) - # Bulk insert summaries - if summary_objects: - with rls_transaction(tenant_id): + # Idempotent re-run: clear this scan's prior summaries before re-inserting, so + # a recovered scan's summary always reflects its own (re-derived) requirement + # rows rather than keeping a stale row (bulk_create ignore_conflicts alone would + # keep the old one). + with rls_transaction(tenant_id): + ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() + if summary_objects: ComplianceOverviewSummary.objects.bulk_create( summary_objects, batch_size=500, ignore_conflicts=True ) @@ -1022,6 +1039,7 @@ def perform_prowler_scan( scan_instance.state = StateChoices.EXECUTING scan_instance.started_at = datetime.now(tz=timezone.utc) scan_instance.save(update_fields=["state", "started_at", "updated_at"]) + _clear_scan_rerun_state(tenant_id, scan_id) # Find the mutelist processor if it exists with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -1651,6 +1669,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): elif requirement_status == "PASS": requirement_statuses[key]["pass_count"] += 1 + # Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first. + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() + # Bulk create requirement records using PostgreSQL COPY _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index b35b68893d..92c2604942 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -46,6 +46,7 @@ from tasks.jobs.lighthouse_providers import ( refresh_lighthouse_provider_models, ) from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.orphan_recovery import reconcile_orphans from tasks.jobs.report import ( STALE_TMP_OUTPUT_MAX_AGE_HOURS, _cleanup_stale_tmp_output_directories, @@ -462,6 +463,12 @@ def cleanup_stale_attack_paths_scans_task(): return cleanup_stale_attack_paths_scans() +@shared_task(name="reconcile-orphan-tasks", queue="celery") +def reconcile_orphan_tasks_task(): + """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" + return reconcile_orphans() + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 0ed8c5ddb2..e6cd51aca8 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,11 +1,12 @@ from unittest.mock import call, patch +from uuid import uuid4 import pytest from django.core.exceptions import ObjectDoesNotExist from tasks.jobs.deletion import delete_provider, delete_tenant from api.attack_paths import database as graph_database -from api.models import Provider, Tenant, TenantComplianceSummary +from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary @pytest.mark.django_db @@ -34,6 +35,43 @@ class TestDeleteProvider: str(instance.id), ) + def test_delete_provider_removes_jira_dispatches( + self, + providers_fixture, + findings_fixture, + integrations_fixture, + ): + """Deleting a provider removes JiraIssueDispatch rows for its findings only.""" + instance = providers_fixture[0] + tenant_id = str(instance.tenant_id) + finding = findings_fixture[0] + integration = integrations_fixture[0] + + # Dispatch for one of the provider's findings: must be removed with it. + JiraIssueDispatch.objects.create( + tenant_id=tenant_id, + integration=integration, + finding_id=finding.id, + ) + # Dispatch for an unrelated finding: must survive the provider deletion. + unrelated = JiraIssueDispatch.objects.create( + tenant_id=tenant_id, + integration=integration, + finding_id=uuid4(), + ) + + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ), + patch("tasks.jobs.deletion.graph_database.drop_subgraph"), + ): + delete_provider(tenant_id, instance.id) + + assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists() + assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists() + def test_delete_provider_does_not_exist(self, tenants_fixture): with ( patch( diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index e246405cdd..ba8d52c193 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -1640,14 +1640,74 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") + def test_send_findings_to_jira_skips_already_dispatched( + self, + mock_jira_dispatch, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """A re-run skips findings already ticketed (no duplicate Jira issues).""" + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + mock_integration_model.objects.get.return_value = MagicMock() + # finding-1 was already dispatched in a prior run; finding-2 is new. + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [ + "finding-1" + ] + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) + + mock_jira_integration = MagicMock() + mock_jira_integration.send_finding.return_value = True + mock_initialize_integration.return_value = mock_jira_integration + + finding2 = MagicMock() + finding2.id = "finding-2" + finding2.check_id = "check_002" + finding2.severity = "low" + finding2.status = "FAIL" + finding2.status_extended = "" + finding2.compliance = {} + finding2.resources.exists.return_value = False + finding2.resources.first.return_value = None + finding2.scan.provider.provider = "aws" + finding2.check_metadata = { + "checktitle": "C2", + "risk": "", + "remediation": {"recommendation": {}, "code": {}}, + } + mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2 + + result = send_findings_to_jira( + "tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"] + ) + + # finding-1 skipped (already sent); only finding-2 sent -> no duplicate. + assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1} + mock_jira_integration.send_finding.assert_called_once() + assert ( + mock_jira_integration.send_finding.call_args.kwargs["check_id"] + == "check_002" + ) + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_success( self, + mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test successful sending of findings to Jira using send_finding method""" + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1739,7 +1799,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 2, "failed_count": 0} + assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0} # Verify Jira integration was initialized mock_initialize_integration.assert_called_once_with(integration) @@ -1771,8 +1831,10 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") @patch("tasks.jobs.integrations.logger") + @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_partial_failure( self, + mock_jira_dispatch, mock_logger, mock_initialize_integration, mock_integration_model, @@ -1780,6 +1842,8 @@ class TestJiraIntegration: mock_rls_transaction, ): """Test partial failure when sending findings to Jira""" + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1833,23 +1897,35 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 2, "failed_count": 1} + assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0} # Verify error was logged for the failed finding mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira") + # The failed finding's reservation is released so a later run can retry it. + mock_jira_dispatch.objects.filter.assert_any_call( + tenant_id=tenant_id, + integration_id=integration_id, + finding_id="finding-2", + ) + mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once() + @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_no_resources( self, + mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test sending findings to Jira when finding has no resources""" + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1907,7 +1983,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 1, "failed_count": 0} + assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} # Verify send_finding was called with empty resource fields call_kwargs = mock_jira_integration.send_finding.call_args.kwargs @@ -1920,14 +1996,18 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_with_empty_check_metadata( self, + mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test sending findings to Jira when check_metadata is empty or missing fields""" + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1970,7 +2050,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 1, "failed_count": 0} + assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} # Verify send_finding was called with default/empty values call_kwargs = mock_jira_integration.send_finding.call_args.kwargs @@ -1983,3 +2063,94 @@ class TestJiraIntegration: assert call_kwargs["remediation_code_cli"] == "" assert call_kwargs["remediation_code_other"] == "" assert call_kwargs["compliance"] == {} + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") + def test_send_findings_to_jira_reserves_before_sending( + self, + mock_jira_dispatch, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """The dispatch row is reserved before the external Jira call (reserve-then-act).""" + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + mock_integration_model.objects.get.return_value = MagicMock() + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + + order = [] + mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: ( + order.append(("reserve", kw)) or (MagicMock(), True) + ) + + mock_jira_integration = MagicMock() + mock_jira_integration.send_finding.side_effect = lambda **kw: ( + order.append(("send", kw)) or True + ) + mock_initialize_integration.return_value = mock_jira_integration + + finding = MagicMock() + finding.id = "finding-1" + finding.check_id = "check_001" + finding.severity = "low" + finding.status = "FAIL" + finding.status_extended = "" + finding.compliance = {} + finding.resources.exists.return_value = False + finding.resources.first.return_value = None + finding.scan.provider.provider = "aws" + finding.check_metadata = { + "checktitle": "C1", + "risk": "", + "remediation": {"recommendation": {}, "code": {}}, + } + mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding + + result = send_findings_to_jira( + "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"] + ) + + assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} + # Reservation must precede the external send. + assert [entry[0] for entry in order] == ["reserve", "send"] + # A successful send keeps the reservation (no rollback delete). + mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called() + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.JiraIssueDispatch") + def test_send_findings_to_jira_skips_when_already_reserved( + self, + mock_jira_dispatch, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """A finding that races past the bulk pre-check but loses the reservation + (created=False) is skipped without a second issue, leaving the row intact.""" + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + mock_integration_model.objects.get.return_value = MagicMock() + mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] + # Another concurrent run already created the dispatch row. + mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False) + + mock_jira_integration = MagicMock() + mock_initialize_integration.return_value = mock_jira_integration + + result = send_findings_to_jira( + "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"] + ) + + assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1} + mock_jira_integration.send_finding.assert_not_called() + # The reservation belongs to the run that won the race; do not delete it. + mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called() diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py new file mode 100644 index 0000000000..b426297bbd --- /dev/null +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -0,0 +1,372 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from celery import states +from django_celery_results.models import TaskResult + +from api.models import Scan, StateChoices +from api.models import Task as APITask +from tasks.jobs.orphan_recovery import ( + _decode_celery_field, + _reconcile_task_results, + _recovery_attempt_count, + _reenqueue_scan, + advisory_lock, + is_worker_alive, +) + + +def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED): + """Create a TaskResult mimicking an in-flight task, backdated past the grace.""" + tr = TaskResult.objects.create( + task_id=str(uuid4()), + status=status, + task_name=name, + worker=worker, + task_kwargs=repr(kwargs), + task_args=repr([]), + ) + TaskResult.objects.filter(pk=tr.pk).update( + date_created=datetime.now(tz=timezone.utc) + - timedelta(minutes=created_minutes_ago) + ) + tr.refresh_from_db() + return tr + + +@pytest.mark.django_db +class TestDecodeCeleryField: + def test_decodes_single_encoded_repr(self): + assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"} + + def test_decodes_double_encoded(self): + import json + + stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"})) + assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"} + + def test_empty_returns_default(self): + assert _decode_celery_field(None, {}) == {} + assert _decode_celery_field("", []) == [] + + def test_unparseable_raises(self): + with pytest.raises(ValueError): + _decode_celery_field("<>", {}) + + +@pytest.mark.django_db +class TestReconcileTaskResults: + def _patches(self, alive): + """Patch worker liveness, revoke, and the task registry for re-enqueue.""" + mock_app = MagicMock() + mock_task = MagicMock() + mock_app.tasks.get.return_value = mock_task + return ( + patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive), + patch("tasks.jobs.orphan_recovery.revoke_task"), + patch("tasks.jobs.orphan_recovery.current_app", mock_app), + mock_task, + ) + + def test_recovers_non_scan_task(self, tenants_fixture): + """A NON-scan task (tenant-deletion) left orphaned is re-enqueued too.""" + tenant = tenants_fixture[0] + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenant.id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["recovered"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_called_once() + call = mock_task.apply_async.call_args.kwargs + assert call["kwargs"] == {"tenant_id": str(tenant.id)} + assert call["task_id"] != tr.task_id # fresh task id + + def test_external_integration_task_is_not_reenqueued_by_default( + self, tenants_fixture + ): + """External side-effect tasks without proven idempotency stay terminal. + + integration-s3 rebuilds its upload from worker-local files that do not + survive the crash, so re-enqueuing it would upload nothing. + """ + tr = _orphan_result( + name="integration-s3", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "provider_id": str(uuid4()), + "output_directory": "/tmp/gone", + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + def test_jira_integration_task_is_reenqueued(self, tenants_fixture): + """integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a + re-run skip already-ticketed findings, so recovery cannot duplicate issues.""" + tenant = tenants_fixture[0] + kwargs = { + "tenant_id": str(tenant.id), + "integration_id": str(uuid4()), + "project_key": "PROWLER", + "issue_type": "Task", + "finding_ids": [str(uuid4()), str(uuid4())], + } + tr = _orphan_result( + name="integration-jira", + kwargs=kwargs, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["recovered"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_called_once() + call = mock_task.apply_async.call_args.kwargs + assert call["kwargs"] == kwargs + assert call["task_id"] != tr.task_id # fresh task id + + def test_skips_live_worker(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="alive@host", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=True) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_skips_recently_created(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=0, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + # too recent: excluded by the grace window (not even a candidate) + assert tr.task_id not in result["recovered"] + mock_task.apply_async.assert_not_called() + + def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture): + """A non-allowlisted task is failed, never blind re-run.""" + tr = _orphan_result( + name="some-non-idempotent-task", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + tr.refresh_from_db() + assert tr.status == states.REVOKED + mock_task.apply_async.assert_not_called() + + def test_recovery_cap_marks_failed(self, tenants_fixture): + """When the recovery counter exceeds the cap, the task is failed not re-run.""" + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + +@pytest.mark.django_db +class TestScanRecovery: + """Scans are recovered by re-running scan-perform on the EXISTING scan row, + so even a scheduled-scan orphan (whose own task would no-op on its guard) is + actually re-executed.""" + + def _scan_orphan(self, tenant, provider, name): + old_id = str(uuid4()) + tr = TaskResult.objects.create( + task_id=old_id, + status=states.STARTED, + task_name=name, + worker="dead@gone", + task_kwargs=repr( + {"tenant_id": str(tenant.id), "provider_id": str(provider.id)} + ), + task_args=repr([]), + ) + TaskResult.objects.filter(pk=tr.pk).update( + date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60) + ) + APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr) + scan = Scan.objects.create( + name="scan-orphan", + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.EXECUTING, + tenant_id=tenant.id, + task_id=old_id, + recovery_count=0, + ) + return old_id, scan + + @pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"]) + def test_scan_recovered_via_scan_perform( + self, tenants_fixture, providers_fixture, name + ): + tenant, provider = tenants_fixture[0], providers_fixture[0] + old_id, scan = self._scan_orphan(tenant, provider, name) + + with ( + patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False), + patch("tasks.jobs.orphan_recovery.revoke_task"), + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + patch("tasks.tasks.perform_scan_task") as mock_scan_task, + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert old_id in result["recovered"] + scan.refresh_from_db() + assert str(scan.task_id) != old_id # relinked to a fresh task + assert scan.recovery_count == 1 + assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED + # Recovered by re-running scan-perform on the existing scan row (so the + # scheduled guard cannot no-op it), regardless of the original task name. + mock_scan_task.apply_async.assert_called_once() + assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str( + scan.id + ) + + def test_reenqueue_skips_when_scan_already_repointed( + self, tenants_fixture, providers_fixture + ): + # The scan already points at a newer task, so a stale orphan must not relink + # it or launch a second concurrent run against the same scan row. + tenant, provider = tenants_fixture[0], providers_fixture[0] + newer_id = str(uuid4()) + tr = TaskResult.objects.create( + task_id=newer_id, status=states.STARTED, task_name="scan-perform" + ) + APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr) + scan = Scan.objects.create( + name="scan-orphan", + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.EXECUTING, + tenant_id=tenant.id, + task_id=newer_id, + recovery_count=0, + ) + + with patch("tasks.tasks.perform_scan_task") as mock_scan_task: + recovered = _reenqueue_scan(str(uuid4()), scan) + + assert recovered is False + mock_scan_task.apply_async.assert_not_called() + scan.refresh_from_db() + assert scan.recovery_count == 0 + + +@pytest.mark.django_db +class TestOrphanRecoveryHelpers: + def test_advisory_lock_acquires_and_releases(self): + with advisory_lock() as acquired: + assert acquired is True + + def test_is_worker_alive_true_when_responds(self): + inspect = MagicMock() + inspect.ping.return_value = {"w@h": {"ok": "pong"}} + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is True + + def test_is_worker_alive_false_when_silent(self): + inspect = MagicMock() + inspect.ping.return_value = None + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is False + + def test_recovery_attempt_count_increments(self): + # Unique signature so the Valkey counter starts fresh for this test. + kwargs_repr = repr({"probe": str(uuid4())}) + redis_client = MagicMock() + redis_client.incr.side_effect = [1, 2] + with patch("redis.from_url", return_value=redis_client): + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1 + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2 diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 0a7193cd4d..2ff7e44e4d 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -32,12 +32,15 @@ from tasks.utils import CustomEncoder from api.db_router import MainRouter from api.exceptions import ProviderConnectionError from api.models import ( + AttackSurfaceOverview, Finding, MuteRule, Provider, Resource, ResourceScanSummary, Scan, + ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, StatusChoices, @@ -229,6 +232,131 @@ class TestPerformScan: # Assert that failed_findings_count is 0 (finding is PASS and muted) assert scan_resource.failed_findings_count == 0 + def test_perform_prowler_scan_idempotent_on_rerun( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Re-running a scan for the same scan_id must not duplicate findings.""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks, + ): + mock_checks["aws"] = {"check1": {"compliance1"}} + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + stale_resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="stale_resource_uid", + name="stale", + region="stale-region", + service="stale-service", + type="stale-type", + ) + ResourceScanSummary.objects.create( + tenant_id=tenant.id, + scan_id=scan.id, + resource_id=stale_resource.id, + service="stale-service", + region="stale-region", + resource_type="stale-type", + ) + ScanCategorySummary.objects.create( + tenant_id=tenant.id, + scan=scan, + category="stale-category", + severity=Severity.medium, + total_findings=1, + ) + ScanGroupSummary.objects.create( + tenant_id=tenant.id, + scan=scan, + resource_group="stale-group", + severity=Severity.medium, + total_findings=1, + ) + ScanSummary.objects.create( + tenant_id=tenant.id, + scan=scan, + check_id="stale_check", + service="stale-service", + severity=Severity.medium, + region="stale-region", + total=1, + ) + AttackSurfaceOverview.objects.create( + tenant_id=tenant.id, + scan=scan, + attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS, + total_findings=1, + ) + + finding = MagicMock() + finding.uid = "dup_probe_finding" + finding.status = StatusChoices.PASS + finding.status_extended = "x" + finding.severity = Severity.medium + finding.check_id = "check1" + finding.get_metadata.return_value = {"key": "value"} + finding.resource_uid = "resource_uid" + finding.resource_name = "resource_name" + finding.region = "region" + finding.service_name = "service_name" + finding.resource_type = "resource_type" + finding.resource_tags = {} + finding.muted = False + finding.raw = {} + finding.resource_metadata = {} + finding.resource_details = {} + finding.partition = "partition" + finding.compliance = {} + + mock_scan_instance = MagicMock() + mock_scan_instance.scan.return_value = [(100, [finding])] + mock_prowler_scan_class.return_value = mock_scan_instance + + mock_provider_instance = MagicMock() + mock_provider_instance.get_regions.return_value = ["region"] + mock_initialize_prowler_provider.return_value = mock_provider_instance + + # Run the same scan twice (simulating an orphan-recovery re-run). + perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"]) + perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"]) + + # Neither findings nor resources are duplicated by the re-run: findings are + # scope-deleted before re-insert; resources are upserted by (tenant, provider, uid). + assert Finding.objects.filter(scan=scan).count() == 1 + assert Resource.objects.filter(provider=provider).count() == 2 + assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1 + assert not ResourceScanSummary.objects.filter( + scan_id=scan.id, resource_id=stale_resource.id + ).exists() + assert not ScanCategorySummary.objects.filter(scan=scan).exists() + assert not ScanGroupSummary.objects.filter(scan=scan).exists() + assert not ScanSummary.objects.filter( + scan=scan, check_id="stale_check" + ).exists() + assert not AttackSurfaceOverview.objects.filter(scan=scan).exists() + @patch("tasks.jobs.scan.ProwlerScan") @patch( "tasks.jobs.scan.initialize_prowler_provider", @@ -1880,6 +2008,62 @@ class TestCreateComplianceRequirements: assert "requirements_created" in result + @pytest.mark.django_db(transaction=True) + def test_create_compliance_requirements_idempotent_on_rerun( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + findings_fixture, + ): + """Re-running compliance materialization must not raise nor duplicate rows. + + Uses transaction=True because the COPY path commits on its own connection, + so the test must use real commits (mirroring production) rather than the + default rollback wrapper. + """ + from api.models import ComplianceRequirementOverview + + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"test_check_id": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + }, + }, + } + } + + create_compliance_requirements(tenant_id, scan_id) + count_after_first = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + # Second run must not raise and must not duplicate rows. + create_compliance_requirements(tenant_id, scan_id) + count_after_second = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + assert count_after_first > 0 + assert count_after_second == count_after_first + def test_create_compliance_requirements_kubernetes_provider( self, tenants_fixture, diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 7a52c27323..f62f5684cc 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -2706,3 +2706,36 @@ class TestReaggregateAllFindingGroupSummaries: assert result == {"scans_reaggregated": 0} mock_group.assert_not_called() mock_chain.assert_not_called() + + +class TestTaskTimeLimits: + """The per-task limits in task_annotations must actually take effect. + + Celery applies a "*" annotation after the per-task one, so a "*" entry would + silently overwrite every specific limit and cap long scans at the default. The + default is set as the global limit instead, and these per-task limits must win. + """ + + def test_long_running_tasks_exceed_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ): + assert celery_app.tasks[name].time_limit > default + + def test_connection_checks_stay_below_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ): + assert celery_app.tasks[name].time_limit < default diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 2020c7d21a..522aaf5413 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -139,6 +139,8 @@ services: worker-dev: image: prowler-api-dev + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s build: context: ./api dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index a9d2e03c3d..1519946afd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,6 +129,8 @@ services: worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s env_file: - path: .env required: false From d573af911d3d3b4ee469624c15d62a059ea7278a Mon Sep 17 00:00:00 2001 From: RishiWig3 <54427319+RishiWig3@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:10:13 +1000 Subject: [PATCH 005/129] feat(aws): add sagemaker_models_monitor_enabled check (#11278) Co-authored-by: RishiWig3 Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- prowler/CHANGELOG.md | 8 + .../__init__.py | 0 ...maker_models_monitor_enabled.metadata.json | 40 +++ .../sagemaker_models_monitor_enabled.py | 22 ++ .../services/sagemaker/sagemaker_service.py | 50 ++++ .../sagemaker_models_monitor_enabled_test.py | 229 ++++++++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py create mode 100644 tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index cbf3e84737..4f89a62992 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.30.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) + +--- + ## [5.29.1] (Prowler v5.29.1) ### 🐞 Fixed diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json new file mode 100644 index 0000000000..d1d025fe7e --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_models_monitor_enabled", + "CheckTitle": "Amazon SageMaker has a monitoring schedule scheduled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Models Monitor** detects data drift, model quality issues, and bias drift in production.", + "Risk": "Without an **active monitoring schedule**, data drift, model quality issues, and bias drift go undetected, so **model quality degrades silently** while downstream decisions such as fraud detection, access control, and pricing keep relying on a degrading model.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor-scheduling.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Amazon SageMaker Model Monitor** and keep at least one **monitoring schedule** in the `Scheduled` state so data quality, model quality, and bias drift are continuously evaluated against a baseline.", + "Url": "https://hub.prowler.com/check/sagemaker_models_monitor_enabled" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py new file mode 100644 index 0000000000..f9f893a895 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_models_monitor_enabled(Check): + def execute(self): + findings = [] + for monitoring_schedule in sagemaker_client.sagemaker_monitoring_schedules: + report = Check_Report_AWS( + metadata=self.metadata(), resource=monitoring_schedule + ) + if monitoring_schedule.is_scheduled: + report.status = "PASS" + report.status_extended = f"SageMaker monitoring schedule {monitoring_schedule.name} is enabled in region {monitoring_schedule.region}." + elif not monitoring_schedule.has_schedules: + report.status = "FAIL" + report.status_extended = f"No SageMaker monitoring schedules found in region {monitoring_schedule.region}." + else: + report.status = "FAIL" + report.status_extended = f"No active SageMaker monitoring schedule in region {monitoring_schedule.region}; existing schedules are not in Scheduled status." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 0f73062452..5093a4ac02 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -18,6 +18,7 @@ class SageMaker(AWSService): self.sagemaker_domains = [] self.endpoint_configs = {} self.sagemaker_model_registries = [] + self.sagemaker_monitoring_schedules = [] # Retrieve resources concurrently self.__threading_call__(self._list_notebook_instances) @@ -26,6 +27,7 @@ class SageMaker(AWSService): self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) self.__threading_call__(self._list_model_package_groups) + self.__threading_call__(self._list_monitoring_schedules) # Describe resources concurrently self.__threading_call__(self._describe_model, self.sagemaker_models) @@ -377,6 +379,46 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_monitoring_schedules(self, regional_client): + logger.info("SageMaker - listing monitoring schedules...") + name = "SageMaker Monitoring Schedules" + arn = self.get_unknown_arn( + region=regional_client.region, + resource_type="monitoring-schedule", + ) + has_schedules = False + is_scheduled = False + try: + paginator = regional_client.get_paginator("list_monitoring_schedules") + for page in paginator.paginate(): + for schedule in page["MonitoringScheduleSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + schedule["MonitoringScheduleArn"], self.audit_resources + ) + ): + has_schedules = True + if schedule["MonitoringScheduleStatus"] == "Scheduled": + is_scheduled = True + name = schedule["MonitoringScheduleName"] + arn = schedule["MonitoringScheduleArn"] + break + if is_scheduled: + break + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.sagemaker_monitoring_schedules.append( + MonitoringSchedule( + name=name, + region=regional_client.region, + arn=arn, + has_schedules=has_schedules, + is_scheduled=is_scheduled, + ) + ) + class NotebookInstance(BaseModel): name: str @@ -441,3 +483,11 @@ class ModelRegistry(BaseModel): region: str has_groups: bool = False has_approved_packages: bool = False + + +class MonitoringSchedule(BaseModel): + name: str + region: str + arn: str + has_schedules: bool = False + is_scheduled: bool = False diff --git a/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py new file mode 100644 index 0000000000..afdb5c6ff7 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py @@ -0,0 +1,229 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ( + MonitoringSchedule, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +test_monitoring_schedule = "test-monitoring-schedule" +monitoring_schedule_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{test_monitoring_schedule}" + +aggregate_name = "SageMaker Monitoring Schedules" +unknown_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/unknown" + + +class Test_sagemaker_models_monitor_enabled: + def test_no_models_monitoring_schedules_exist(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == aggregate_name + assert result[0].resource_arn == unknown_arn + + def test_region_with_schedules_but_none_scheduled(self): + # A region that has monitoring schedules but none in Scheduled state + # must FAIL once. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=True, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No active SageMaker monitoring schedule in region {AWS_REGION_EU_WEST_1}; existing schedules are not in Scheduled status." + ) + + def test_region_with_one_scheduled_passes(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=test_monitoring_schedule, + region=AWS_REGION_EU_WEST_1, + arn=monitoring_schedule_arn, + has_schedules=True, + is_scheduled=True, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"SageMaker monitoring schedule {test_monitoring_schedule} is enabled in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == test_monitoring_schedule + assert result[0].resource_arn == monitoring_schedule_arn + + def test_scheduled_not_masked_across_regions(self): + # Regression: a region without an active monitor must not mask a + # Scheduled monitor in another region; one finding per region. + scheduled_name = "scheduled-monitor" + scheduled_arn = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{scheduled_name}" + + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ), + MonitoringSchedule( + name=scheduled_name, + region=AWS_REGION_US_EAST_1, + arn=scheduled_arn, + has_schedules=True, + is_scheduled=True, + ), + ] + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + assert results_by_region[AWS_REGION_EU_WEST_1].status == "FAIL" + assert ( + results_by_region[AWS_REGION_EU_WEST_1].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + + assert results_by_region[AWS_REGION_US_EAST_1].status == "PASS" + assert ( + results_by_region[AWS_REGION_US_EAST_1].status_extended + == f"SageMaker monitoring schedule {scheduled_name} is enabled in region {AWS_REGION_US_EAST_1}." + ) + + def test_empty_schedules_list(self): + # Regression: an empty list must not raise and must yield no findings. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert result == [] From f7f8747512e30c8dda7a0b34ffefaa8ad233db3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 3 Jun 2026 11:43:55 +0200 Subject: [PATCH 006/129] feat(compliance): add DORA framework for AWS (#11131) --- api/CHANGELOG.md | 3 +- api/src/backend/api/compliance.py | 83 +- api/src/backend/api/specs/v1.yaml | 53 +- api/src/backend/api/tests/test_compliance.py | 91 +- api/src/backend/api/tests/test_views.py | 10 + api/src/backend/api/v1/views.py | 149 +- api/src/backend/tasks/jobs/export.py | 10 - api/src/backend/tasks/jobs/report.py | 17 +- api/src/backend/tasks/jobs/reports/base.py | 61 +- .../backend/tasks/jobs/threatscore_utils.py | 49 +- api/src/backend/tasks/tasks.py | 45 +- .../backend/tasks/tests/test_reports_csa.py | 2 +- api/src/backend/tasks/tests/test_tasks.py | 10 + .../security-compliance-framework.mdx | 421 +- prowler/CHANGELOG.md | 1 + prowler/__main__.py | 65 - .../csa_ccm_4.0_alibabacloud.json | 7305 ---------------- prowler/compliance/aws/csa_ccm_4.0_aws.json | 7617 ----------------- .../compliance/azure/csa_ccm_4.0_azure.json | 7548 ---------------- prowler/compliance/dora.json | 597 ++ prowler/compliance/gcp/csa_ccm_4.0_gcp.json | 7386 ---------------- .../oraclecloud/csa_ccm_4.0_oraclecloud.json | 7307 ---------------- prowler/lib/outputs/compliance/compliance.py | 72 +- .../lib/outputs/compliance/csa/__init__.py | 0 prowler/lib/outputs/compliance/csa/csa.py | 101 - .../compliance/csa/csa_alibabacloud.py | 95 - prowler/lib/outputs/compliance/csa/csa_aws.py | 95 - .../lib/outputs/compliance/csa/csa_azure.py | 95 - prowler/lib/outputs/compliance/csa/csa_gcp.py | 95 - .../outputs/compliance/csa/csa_oraclecloud.py | 95 - prowler/lib/outputs/compliance/csa/models.py | 146 - .../compliance/universal/ocsf_compliance.py | 59 +- .../compliance/universal/universal_output.py | 12 +- .../display_compliance_table_test.py | 15 - .../compliance/process_universal_test.py | 276 + .../universal/ocsf_compliance_test.py | 120 + .../universal/universal_output_test.py | 37 + ui/CHANGELOG.md | 8 + ui/actions/scans/scans.ts | 21 + .../dora-details.tsx | 49 + .../compliance-download-container.test.tsx | 63 +- .../compliance-download-container.tsx | 63 +- .../icons/compliance/IconCompliance.tsx | 4 + ui/components/icons/compliance/dora.svg | 13 + ui/lib/compliance/compliance-mapper.ts | 18 + .../compliance-report-types.test.ts | 21 +- ui/lib/compliance/compliance-report-types.ts | 24 + ui/lib/compliance/dora.tsx | 154 + ui/lib/helper.ts | 27 + ui/types/compliance.ts | 26 + 50 files changed, 2357 insertions(+), 38277 deletions(-) delete mode 100644 prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json delete mode 100644 prowler/compliance/aws/csa_ccm_4.0_aws.json delete mode 100644 prowler/compliance/azure/csa_ccm_4.0_azure.json create mode 100644 prowler/compliance/dora.json delete mode 100644 prowler/compliance/gcp/csa_ccm_4.0_gcp.json delete mode 100644 prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json delete mode 100644 prowler/lib/outputs/compliance/csa/__init__.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa_alibabacloud.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa_aws.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa_azure.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa_gcp.py delete mode 100644 prowler/lib/outputs/compliance/csa/csa_oraclecloud.py delete mode 100644 prowler/lib/outputs/compliance/csa/models.py create mode 100644 ui/components/compliance/compliance-custom-details/dora-details.tsx create mode 100644 ui/components/icons/compliance/dora.svg create mode 100644 ui/lib/compliance/dora.tsx diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bf95d3c569..4f4d3426f6 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,12 +2,13 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.31.0] (Prowler v5.30.0) +## [1.31.0] (Prowler UNRELEASED) ### 🚀 Added - Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) ### 🔄 Changed diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 25b8fb6735..678aff8d57 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,7 +1,9 @@ from collections.abc import Iterable, Mapping from api.models import Provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) from prowler.lib.check.models import CheckMetadata AVAILABLE_COMPLIANCE_FRAMEWORKS = {} @@ -94,25 +96,22 @@ PROWLER_CHECKS = LazyChecksMapping() def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]: - """List compliance frameworks the API can load for `provider_type`. + """List compliance framework identifiers available for `provider_type`. - The list is sourced from `Compliance.get_bulk` so that the names - returned here are guaranteed to be loadable by the bulk loader. This - prevents downstream key mismatches (e.g. CSV report generation iterating - framework names and looking them up in the bulk dict). + Includes both per-provider frameworks and universal top-level frameworks + (e.g. ``dora``, ``csa_ccm_4.0``). Args: - provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve - available compliance frameworks (e.g., "aws", "azure", "gcp", "m365"). + provider_type (Provider.ProviderChoices): The cloud provider type + (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available - for the given provider. + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list( - Compliance.get_bulk(provider_type).keys() + get_bulk_compliance_frameworks_universal(provider_type).keys() ) return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] @@ -139,18 +138,14 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> """ Retrieve the Prowler compliance data for a specified provider type. - This function fetches the compliance frameworks and their associated - requirements for the given cloud provider. - Args: provider_type (Provider.ProviderChoices): The provider type (e.g., 'aws', 'azure') for which to retrieve compliance data. Returns: - dict: A dictionary mapping compliance framework names to their respective - Compliance objects for the specified provider. + dict: Mapping of framework name to `ComplianceFramework` for the provider. """ - return Compliance.get_bulk(provider_type) + return get_bulk_compliance_frameworks_universal(provider_type) def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]: @@ -209,8 +204,8 @@ def load_prowler_checks( for compliance_name, compliance_data in prowler_compliance.get( provider_type, {} ).items(): - for requirement in compliance_data.Requirements: - for check in requirement.Checks: + for requirement in compliance_data.requirements: + for check in requirement.checks.get(provider_type, []): try: checks[provider_type][check].add(compliance_name) except KeyError: @@ -290,24 +285,40 @@ def generate_compliance_overview_template( requirements_status = {"passed": 0, "failed": 0, "manual": 0} total_requirements = 0 - for requirement in compliance_data.Requirements: + for requirement in compliance_data.requirements: total_requirements += 1 - total_checks = len(requirement.Checks) - checks_dict = {check: None for check in requirement.Checks} + provider_check_list = list(requirement.checks.get(provider_type, [])) + total_checks = len(provider_check_list) + checks_dict = {check: None for check in provider_check_list} req_status_val = "MANUAL" if total_checks == 0 else "PASS" + # MITRE attrs are wrapped under `_raw_attributes` by the + # universal adapter — unwrap so consumers see the flat list. + requirement_attributes = requirement.attributes + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + attributes_payload = list(requirement_attributes["_raw_attributes"]) + elif isinstance(requirement_attributes, dict): + attributes_payload = ( + [dict(requirement_attributes)] if requirement_attributes else [] + ) + else: + attributes_payload = [ + dict(attribute) for attribute in requirement_attributes + ] + # Build requirement dictionary requirement_dict = { - "name": requirement.Name or requirement.Id, - "description": requirement.Description, - "tactics": getattr(requirement, "Tactics", []), - "subtechniques": getattr(requirement, "SubTechniques", []), - "platforms": getattr(requirement, "Platforms", []), - "technique_url": getattr(requirement, "TechniqueURL", ""), - "attributes": [ - dict(attribute) for attribute in requirement.Attributes - ], + "name": requirement.name or requirement.id, + "description": requirement.description, + "tactics": requirement.tactics or [], + "subtechniques": requirement.sub_techniques or [], + "platforms": requirement.platforms or [], + "technique_url": requirement.technique_url or "", + "attributes": attributes_payload, "checks": checks_dict, "checks_status": { "pass": 0, @@ -325,15 +336,15 @@ def generate_compliance_overview_template( requirements_status["passed"] += 1 # Add requirement to compliance requirements - compliance_requirements[requirement.Id] = requirement_dict + compliance_requirements[requirement.id] = requirement_dict # Build compliance dictionary compliance_dict = { - "framework": compliance_data.Framework, - "name": compliance_data.Name, - "version": compliance_data.Version, + "framework": compliance_data.framework, + "name": compliance_data.name, + "version": compliance_data.version, "provider": provider_type, - "description": compliance_data.Description, + "description": compliance_data.description, "requirements": compliance_requirements, "requirements_status": requirements_status, "total_requirements": total_requirements, diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 5efefc0790..d2a30ca897 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -13137,8 +13137,59 @@ paths: responses: '200': description: CSV file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials '404': - description: Compliance report not found + description: Compliance report not found, or the scan has no reports yet + /api/v1/scans/{id}/compliance/{name}/ocsf: + get: + operationId: scans_compliance_ocsf_retrieve + description: Download a specific compliance report as an OCSF JSON file. Only + universal frameworks that declare an output configuration produce this artifact + (currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404. + summary: Retrieve compliance report as OCSF JSON + parameters: + - in: query + name: fields[scan-reports] + schema: + type: array + items: + type: string + enum: + - id + - name + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: path + name: name + schema: + type: string + description: The compliance report name, like 'dora' + required: true + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: OCSF JSON file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials + '404': + description: Compliance report not found, the framework does not provide + an OCSF export, or the scan has no reports yet /api/v1/scans/{id}/csa: get: operationId: scans_csa_retrieve diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index ce30a3cc52..508e5abaca 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -12,7 +12,9 @@ from api.compliance import ( load_prowler_checks, ) from api.models import Provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) class TestCompliance: @@ -28,16 +30,16 @@ class TestCompliance: assert set(checks) == {"check1", "check2", "check3"} mock_check_metadata.get_bulk.assert_called_once_with(provider_type) - @patch("api.compliance.Compliance") - def test_get_prowler_provider_compliance(self, mock_compliance): + @patch("api.compliance.get_bulk_compliance_frameworks_universal") + def test_get_prowler_provider_compliance(self, mock_get_bulk): provider_type = Provider.ProviderChoices.AWS - mock_compliance.get_bulk.return_value = { + mock_get_bulk.return_value = { "compliance1": MagicMock(), "compliance2": MagicMock(), } compliance_data = get_prowler_provider_compliance(provider_type) - assert compliance_data == mock_compliance.get_bulk.return_value - mock_compliance.get_bulk.assert_called_once_with(provider_type) + assert compliance_data == mock_get_bulk.return_value + mock_get_bulk.assert_called_once_with(provider_type) @patch("api.compliance.get_prowler_provider_checks") @patch("api.models.Provider.ProviderChoices") @@ -51,9 +53,9 @@ class TestCompliance: prowler_compliance = { "aws": { "compliance1": MagicMock( - Requirements=[ + requirements=[ MagicMock( - Checks=["check1", "check2"], + checks={"aws": ["check1", "check2"]}, ), ], ), @@ -167,35 +169,38 @@ class TestCompliance: def test_generate_compliance_overview_template(self, mock_provider_choices): mock_provider_choices.values = ["aws"] + # ``name`` is a reserved MagicMock kwarg (it labels the mock for repr, + # it does NOT set a ``.name`` attribute), so it must be assigned + # explicitly after construction. requirement1 = MagicMock( - Id="requirement1", - Name="Requirement 1", - Description="Description of requirement 1", - Attributes=[], - Checks=["check1", "check2"], - Tactics=["tactic1"], - SubTechniques=["subtechnique1"], - Platforms=["platform1"], - TechniqueURL="https://example.com", + id="requirement1", + description="Description of requirement 1", + attributes=[], + checks={"aws": ["check1", "check2"]}, + tactics=["tactic1"], + sub_techniques=["subtechnique1"], + platforms=["platform1"], + technique_url="https://example.com", ) + requirement1.name = "Requirement 1" requirement2 = MagicMock( - Id="requirement2", - Name="Requirement 2", - Description="Description of requirement 2", - Attributes=[], - Checks=[], - Tactics=[], - SubTechniques=[], - Platforms=[], - TechniqueURL="", + id="requirement2", + description="Description of requirement 2", + attributes=[], + checks={"aws": []}, + tactics=[], + sub_techniques=[], + platforms=[], + technique_url="", ) + requirement2.name = "Requirement 2" compliance1 = MagicMock( - Requirements=[requirement1, requirement2], - Framework="Framework 1", - Version="1.0", - Description="Description of compliance1", - Name="Compliance 1", + requirements=[requirement1, requirement2], + framework="Framework 1", + version="1.0", + description="Description of compliance1", ) + compliance1.name = "Compliance 1" prowler_compliance = {"aws": {"compliance1": compliance1}} template = generate_compliance_overview_template(prowler_compliance) @@ -271,24 +276,28 @@ def reset_compliance_cache(): class TestGetComplianceFrameworks: def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache): - with patch("api.compliance.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = { + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_get_bulk.return_value = { "cis_1.4_aws": MagicMock(), "mitre_attack_aws": MagicMock(), } result = get_compliance_frameworks(Provider.ProviderChoices.AWS) assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"] - mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS) def test_caches_result_per_provider(self, reset_compliance_cache): - with patch("api.compliance.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} get_compliance_frameworks(Provider.ProviderChoices.AWS) get_compliance_frameworks(Provider.ProviderChoices.AWS) # Cached after first call. - assert mock_compliance.get_bulk.call_count == 1 + assert mock_get_bulk.call_count == 1 @pytest.mark.parametrize( "provider_type", @@ -296,17 +305,19 @@ class TestGetComplianceFrameworks: ) def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type): """Regression for CLOUD-API-40S: every name returned by - ``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``. + ``get_compliance_frameworks`` must be loadable via + ``get_bulk_compliance_frameworks_universal``. A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in ``generate_outputs_task`` after universal/multi-provider compliance JSONs were introduced at the top-level ``prowler/compliance/`` path. """ - bulk_keys = set(Compliance.get_bulk(provider_type).keys()) + bulk_keys = set(get_bulk_compliance_frameworks_universal(provider_type).keys()) listed = set(get_compliance_frameworks(provider_type)) missing = listed - bulk_keys assert not missing, ( f"get_compliance_frameworks({provider_type!r}) returned names not " - f"loadable by Compliance.get_bulk: {sorted(missing)}" + f"loadable by get_bulk_compliance_frameworks_universal: " + f"{sorted(missing)}" ) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 02a83a997c..d213e8c855 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9560,6 +9560,16 @@ class TestComplianceOverviewViewSet: assert "platforms" in attributes["attributes"]["technique_details"] assert "technique_url" in attributes["attributes"]["technique_details"] + # Guard against the `_raw_attributes` wrapper leaking through — + # the UI reads metadata[i].Category / .AWSService directly. + metadata = attributes["attributes"]["metadata"] + assert isinstance(metadata, list) and len(metadata) > 0 + first_attr = metadata[0] + assert isinstance(first_attr, dict) + assert "_raw_attributes" not in first_attr + assert "Category" in first_attr + assert "AWSService" in first_attr + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 4a09a04838..9c91c3201f 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -116,6 +116,7 @@ from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, + get_prowler_provider_compliance, ) from api.constants import SEVERITY_ORDER from api.db_router import MainRouter @@ -1849,7 +1850,42 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): 200: OpenApiResponse( description="CSV file containing the compliance report" ), - 404: OpenApiResponse(description="Compliance report not found"), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, or the scan has no reports yet" + ), + }, + request=None, + ), + compliance_ocsf=extend_schema( + tags=["Scan"], + summary="Retrieve compliance report as OCSF JSON", + description=( + "Download a specific compliance report as an OCSF JSON file. " + "Only universal frameworks that declare an output configuration " + "produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any " + "other framework returns 404." + ), + parameters=[ + OpenApiParameter( + name="name", + type=str, + location=OpenApiParameter.PATH, + required=True, + description="The compliance report name, like 'dora'", + ), + ], + responses={ + 200: OpenApiResponse( + description="OCSF JSON file containing the compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, the framework does " + "not provide an OCSF export, or the scan has no reports yet" + ), }, request=None, ), @@ -1992,35 +2028,23 @@ class ScanViewSet(BaseRLSViewSet): return queryset.select_related("provider", "task") def get_serializer_class(self): - if self.action == "create": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanCreateSerializer - elif self.action == "partial_update": + if self.action == "partial_update": return ScanUpdateSerializer - elif self.action == "report": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanReportSerializer - elif self.action == "compliance": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanComplianceReportSerializer - elif self.action == "threatscore": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "ens": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "nis2": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "csa": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "cis": + + action_defaults = { + "create": ScanCreateSerializer, + "report": ScanReportSerializer, + "compliance": ScanComplianceReportSerializer, + "compliance_ocsf": ScanComplianceReportSerializer, + } + response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"} + + if self.action in action_defaults or self.action in response_only_actions: if hasattr(self, "response_serializer_class"): return self.response_serializer_class + if self.action in action_defaults: + return action_defaults[self.action] + return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -2269,20 +2293,16 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "application/x-zip-compressed") - @action( - detail=True, - methods=["get"], - url_path="compliance/(?P[^/]+)", - url_name="compliance", - ) - def compliance(self, request, pk=None, name=None): - scan = self.get_object() - if name not in get_compliance_frameworks(scan.provider.provider): - return Response( - {"detail": f"Compliance '{name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + def _serve_compliance_artifact(self, scan, name, file_extension, content_type): + """Resolve and serve a per-framework compliance artifact from disk/S3. + Shared by the CSV and OCSF compliance download actions. Both are + path-based (no query params) on purpose: ``get_object`` runs + ``filter_queryset``, which triggers JSON:API's + ``QueryParameterValidationFilter`` and 400s on any non-JSON:API + query param, so a ``?format=`` / ``?type=`` selector is not viable + here — the format is encoded in the route instead. + """ running_resp = self._get_task_status(scan) if running_resp: return running_resp @@ -2299,25 +2319,66 @@ class ScanViewSet(BaseRLSViewSet): bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") prefix = os.path.join( - os.path.dirname(key_prefix), "compliance", f"{name}.csv" + os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}" ) loader = self._load_file( prefix, s3=True, bucket=bucket, list_objects=True, - content_type="text/csv", + content_type=content_type, ) else: base = os.path.dirname(scan.output_location) - pattern = os.path.join(base, "compliance", f"*_{name}.csv") + pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}") loader = self._load_file(pattern, s3=False) if isinstance(loader, HttpResponseBase): return loader content, filename = loader - return self._serve_file(content, filename, "text/csv") + return self._serve_file(content, filename, content_type) + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)", + url_name="compliance", + ) + def compliance(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return self._serve_compliance_artifact(scan, name, "csv", "text/csv") + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)/ocsf", + url_name="compliance-ocsf", + ) + def compliance_ocsf(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + universal_bulk = get_prowler_provider_compliance(scan.provider.provider) + framework_obj = universal_bulk.get(name) + if not (framework_obj and getattr(framework_obj, "outputs", None)): + return Response( + {"detail": f"Compliance '{name}' does not provide an OCSF export."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return self._serve_compliance_artifact( + scan, name, "ocsf.json", "application/json" + ) @action( detail=True, diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 1b9295cc67..185acb7f99 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -39,11 +39,6 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import ( GoogleWorkspaceCISASCuBA, ) -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -102,7 +97,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS), (lambda name: name.startswith("ccc_"), CCC_AWS), (lambda name: name.startswith("c5_"), AWSC5), - (lambda name: name.startswith("csa_"), AWSCSA), (lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS), ], "azure": [ @@ -113,7 +107,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name.startswith("ccc_"), CCC_Azure), (lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure), (lambda name: name == "c5_azure", AzureC5), - (lambda name: name.startswith("csa_"), AzureCSA), ], "gcp": [ (lambda name: name.startswith("cis_"), GCPCIS), @@ -123,7 +116,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP), (lambda name: name.startswith("ccc_"), CCC_GCP), (lambda name: name == "c5_gcp", GCPC5), - (lambda name: name.startswith("csa_"), GCPCSA), ], "kubernetes": [ (lambda name: name.startswith("cis_"), KubernetesCIS), @@ -152,11 +144,9 @@ COMPLIANCE_CLASS_MAP = { "image": [], "oraclecloud": [ (lambda name: name.startswith("cis_"), OracleCloudCIS), - (lambda name: name.startswith("csa_"), OracleCloudCSA), ], "alibabacloud": [ (lambda name: name.startswith("cis_"), AlibabaCloudCIS), - (lambda name: name.startswith("csa_"), AlibabaCloudCSA), ( lambda name: name == "prowler_threatscore_alibabacloud", ProwlerThreatScoreAlibaba, diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 4cc3074bcd..36e47829c5 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -29,7 +29,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import rls_transaction from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) @@ -571,7 +574,7 @@ def generate_csa_report( Args: tenant_id: The tenant ID for Row-Level Security context. scan_id: ID of the scan executed by Prowler. - compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws"). + compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0"). output_path: Output PDF file path. provider_id: Provider ID for the scan. only_failed: If True, only include failed requirements in detailed section. @@ -883,9 +886,11 @@ def generate_compliance_reports( frameworks_bulk.get(f"nis2_{provider_type}") ) if generate_csa: - pending_checks_by_framework["csa"] = _get_compliance_check_ids( - frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}") - ) + # csa_ccm_4.0 lives at the top level, not under compliance/{provider}/. + csa_framework = frameworks_bulk.get( + "csa_ccm_4.0" + ) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0") + pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework) if generate_cis and latest_cis: pending_checks_by_framework["cis"] = _get_compliance_check_ids( frameworks_bulk.get(latest_cis) @@ -1183,7 +1188,7 @@ def generate_compliance_reports( if generate_csa: generated_report_keys.append("csa") csa_path = output_paths["csa"] - compliance_id_csa = f"csa_ccm_4.0_{provider_type}" + compliance_id_csa = "csa_ccm_4.0" pdf_path_csa = f"{csa_path}_csa_report.pdf" logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa) diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index 4180c847d8..27d1defff4 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -5,6 +5,7 @@ import time from abc import ABC, abstractmethod from contextlib import contextmanager from dataclasses import dataclass, field +from types import SimpleNamespace from typing import Any from celery.utils.log import get_task_logger @@ -26,7 +27,10 @@ from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.models import Provider, StatusChoices from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput from .components import ( @@ -222,6 +226,46 @@ def get_requirement_metadata( return None +def _universal_attributes_to_list(attributes) -> list: + """Flatten a universal requirement's ``attributes`` into a list of objects + with attribute access. MITRE wraps its list under ``_raw_attributes``.""" + if isinstance(attributes, dict) and "_raw_attributes" in attributes: + entries = attributes.get("_raw_attributes") or [] + return [ + SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict) + ] + if isinstance(attributes, dict): + return [SimpleNamespace(**attributes)] if attributes else [] + return list(attributes or []) + + +def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace: + """Expose a universal ``ComplianceFramework`` under the legacy ``Compliance`` + attribute names used by the PDF pipeline.""" + provider_key = (provider_type or "").lower() + requirements = [] + for requirement in framework.requirements: + checks_by_provider = ( + requirement.checks if isinstance(requirement.checks, dict) else {} + ) + requirements.append( + SimpleNamespace( + Id=requirement.id, + Description=requirement.description or "", + Checks=list(checks_by_provider.get(provider_key, [])), + Attributes=_universal_attributes_to_list(requirement.attributes), + ) + ) + return SimpleNamespace( + Framework=framework.framework, + Name=framework.name, + Version=framework.version or "", + Description=framework.description or "", + Provider=framework.provider or provider_type, + Requirements=requirements, + ) + + # ============================================================================= # PDF Styles Cache # ============================================================================= @@ -869,9 +913,18 @@ class BaseComplianceReportGenerator(ABC): prowler_provider = initialize_prowler_provider(provider_obj) provider_type = provider_obj.provider - # Load compliance framework - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk.get(compliance_id) + # Load compliance framework — fall back to the universal loader + # for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk + # does not scan. + compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id) + if not compliance_obj: + universal_framework = get_bulk_compliance_frameworks_universal( + provider_type + ).get(compliance_id) + if universal_framework: + compliance_obj = _adapt_universal_to_legacy( + universal_framework, provider_type + ) if not compliance_obj: raise ValueError(f"Compliance framework not found: {compliance_id}") diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 7be32c6ade..35fb0faeb3 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -359,35 +359,40 @@ def _load_findings_for_requirement_checks( def _get_compliance_check_ids(compliance_obj) -> set[str]: """Return the union of all check_ids referenced by a compliance framework. - Used by the master report orchestrator to know which checks each - framework consumes from the shared ``findings_cache``, so that once a - framework finishes the entries no other pending framework needs can be - evicted from the cache (PROWLER-1733). + Used by the master report orchestrator to evict entries from + ``findings_cache`` once no pending framework needs them (PROWLER-1733). - Args: - compliance_obj: A loaded Compliance framework object exposing a - ``Requirements`` iterable, each requirement carrying ``Checks``. - ``None`` is treated as "no checks" rather than raising, so the - caller can pass ``frameworks_bulk.get(...)`` directly without - an extra existence check. - - Returns: - Set of check_id strings (empty if ``compliance_obj`` is ``None``). + Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks`` + lists) and the universal ``ComplianceFramework`` shape (``requirements`` + / ``checks`` dict keyed by provider). ``None`` returns an empty set so + callers can pass ``frameworks_bulk.get(...)`` directly. """ if compliance_obj is None: return set() - checks: set[str] = set() - requirements = getattr(compliance_obj, "Requirements", None) or [] + + requirements = getattr(compliance_obj, "Requirements", None) or getattr( + compliance_obj, "requirements", None + ) + if not requirements: + return set() + + check_ids: set[str] = set() try: - # Defensive: Mock objects (used in unit tests) return another Mock - # for any attribute access, which is truthy but not iterable. Treat - # any non-iterable Requirements value as "no checks". - for req in requirements: - req_checks = getattr(req, "Checks", None) or [] + # Mock objects in unit tests return another Mock for any attribute + # access — truthy but not iterable. Treat that as "no checks". + for requirement in requirements: + requirement_checks = getattr(requirement, "Checks", None) + if requirement_checks is None: + checks_by_provider = getattr(requirement, "checks", None) or {} + requirement_checks = [ + check_id + for check_ids_list in checks_by_provider.values() + for check_id in check_ids_list + ] try: - checks.update(req_checks) + check_ids.update(requirement_checks) except TypeError: continue except TypeError: return set() - return checks + return check_ids diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 92c2604942..8f6b9bda0e 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -68,7 +68,10 @@ from tasks.utils import ( get_next_execution_datetime, ) -from api.compliance import get_compliance_frameworks +from api.compliance import ( + get_compliance_frameworks, + get_prowler_provider_compliance, +) from api.db_router import READ_REPLICA_ALIAS from api.db_utils import delete_related_daily_task, rls_transaction from api.decorators import handle_provider_deletion, set_tenant @@ -76,6 +79,9 @@ from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateC from api.utils import initialize_prowler_provider from api.v1.serializers import ScanTaskSerializer from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance import ( + process_universal_compliance_frameworks, +) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.finding import Finding as FindingOutput @@ -543,7 +549,16 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): provider_uid = provider_obj.uid provider_type = provider_obj.provider + # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) + # Universal-only frameworks (top-level JSONs like `dora.json`) are emitted + # via `process_universal_compliance_frameworks` below. + universal_bulk = get_prowler_provider_compliance(provider_type) + universal_only_names = { + name + for name in universal_bulk + if name not in frameworks_bulk and universal_bulk[name].outputs + } frameworks_avail = get_compliance_frameworks(provider_type) out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id @@ -568,6 +583,10 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): output_writers = {} compliance_writers = {} + # Shared across batches so universal writers are created once and reused. + universal_compliance_state: dict[str, list] = {"compliance": []} + universal_base_dir = os.path.dirname(out_dir) + universal_output_filename = os.path.basename(out_dir) scan_summary = FindingOutput._transform_findings_stats( ScanSummary.objects.filter(scan_id=scan_id) @@ -622,8 +641,30 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Compliance CSVs + # Universal-only frameworks (e.g. `dora.json`). + if universal_only_names: + process_universal_compliance_frameworks( + input_compliance_frameworks=universal_only_names, + universal_frameworks=universal_bulk, + finding_outputs=fos, + output_directory=universal_base_dir, + output_filename=universal_output_filename, + provider=provider_type, + generated_outputs=universal_compliance_state, + from_cli=False, + is_last=is_last, + ) + + # Compliance CSVs (per-framework exporters). for name in frameworks_avail: + if name in universal_only_names: + continue + if name not in frameworks_bulk: + logger.warning( + "Compliance framework '%s' missing from bulk; skipping CSV export", + name, + ) + continue compliance_obj = frameworks_bulk[name] klass = GenericCompliance diff --git a/api/src/backend/tasks/tests/test_reports_csa.py b/api/src/backend/tasks/tests/test_reports_csa.py index 602b9bb28e..2e61e9ef84 100644 --- a/api/src/backend/tasks/tests/test_reports_csa.py +++ b/api/src/backend/tasks/tests/test_reports_csa.py @@ -80,7 +80,7 @@ def basic_csa_compliance_data(): tenant_id="tenant-123", scan_id="scan-456", provider_id="provider-789", - compliance_id="csa_ccm_4.0_aws", + compliance_id="csa_ccm_4.0", framework="CSA-CCM", name="CSA Cloud Controls Matrix v4.0", version="4.0", diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index f62f5684cc..67d2c64555 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -323,6 +323,7 @@ class TestGenerateOutputs: mock_transformed_stats = {"some": "stats"} with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch( "tasks.tasks.FindingOutput._transform_findings_stats", return_value=mock_transformed_stats, @@ -441,6 +442,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -596,6 +598,7 @@ class TestGenerateOutputs: ] with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary, patch( "tasks.tasks.Provider.objects.get", @@ -670,6 +673,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -1113,6 +1117,7 @@ class TestCheckIntegrationsTask: enabled=True, ) + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1145,6 +1150,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is generated for AWS providers with SecurityHub integration.""" # Setup @@ -1241,6 +1247,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1273,6 +1280,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for AWS providers without SecurityHub integration.""" # Setup @@ -1366,6 +1374,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.ScanSummary.objects.filter") @patch("tasks.tasks.Provider.objects.get") @patch("tasks.tasks.initialize_prowler_provider") @@ -1394,6 +1403,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider, mock_provider_get, mock_scan_summary, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP).""" # Setup diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 431849689f..030d876aab 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -2,40 +2,228 @@ title: 'Creating a New Security Compliance Framework in Prowler' --- -This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process. +This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the two supported JSON schemas (universal and legacy), the Pydantic models that validate each framework, check mapping conventions, output formatting, local validation, testing, and the pull request process. ## Introduction -A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud. +A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC, DORA) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud. -Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance//` (or `prowler/compliance/` for universal compliance frameworks) +Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance//` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider). -A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement. +A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when no Prowler check can automate it. In that case, leave the requirement's check list empty, but do not omit the requirement. Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework. +### Two supported schemas + +| Schema | When to use | File location | Discovered as | +| --- | --- | --- | --- | +| **Universal (recommended for new frameworks)** | Multi-provider frameworks, or single-provider frameworks that benefit from declarative table/PDF rendering | `prowler/compliance/.json` (top-level) | Available for **every** provider whose key appears in any `requirement.checks` dict | +| **Legacy provider-specific** | Single-provider frameworks with framework-specific attribute classes already declared in the codebase (CIS, ENS, ISO 27001, etc.) | `prowler/compliance//__.json` | Available only under that provider | + +Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py:915`), which scans **both** the top-level `prowler/compliance/` directory and every per-provider sub-directory. Legacy frameworks are transparently converted to the universal `ComplianceFramework` model via `adapt_legacy_to_universal()` before being returned, so the rest of Prowler — CLI table rendering, CSV/OCSF outputs, PDF generation — works the same regardless of the source schema. + +> The legacy entry-point `Compliance.get_bulk(provider)` (used by older code paths) only scans per-provider sub-directories. Universal top-level files are picked up exclusively via the universal loader; this matters if you are wiring a new code path against the legacy API. + +For **new** frameworks, prefer the universal schema: it requires no Python code changes, supports multiple providers in a single file, and table/PDF rendering is driven entirely from declarative configuration inside the JSON. + +> All Pydantic models in `compliance_models.py` are imported from `pydantic.v1`. Subclasses you add for the legacy schema must use `from pydantic.v1 import BaseModel`. + ### Prerequisites Before adding a new framework, complete the following checks: -- **Verify the framework is not already supported.** Inspect `prowler/compliance//` for an existing JSON file matching the name and version. +- **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. - **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. -- **Review a reference framework.** Use an existing framework with a similar structure as your template. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes. +- **Review a reference framework.** Use an existing framework with a similar structure as your template: + - Universal: `prowler/compliance/dora.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. -## Four-Layer Architecture +## Universal Compliance Framework -A compliance framework spans four layers. A complete contribution must touch each layer that applies. +### Where the file lives -- **Layer 1 – Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback). -- **Layer 2 – JSON catalog:** The framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks. -- **Layer 3 – Output formatter:** The Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table. -- **Layer 4 – Output dispatchers:** The dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier. +Place the file at the top level of the compliance directory: -The rest of this guide walks each layer in order. +``` +prowler/compliance/.json +``` -## Directory Structure and File Naming +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.json`. + +The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora.json` → `dora`). + +### Top-level structure + +```json +{ + "framework": "", + "name": "", + "version": "", + "description": "", + "icon": "", + "attributes_metadata": [ /* see below */ ], + "outputs": { /* see below — optional */ }, + "requirements": [ /* see below */ ] +} +``` + +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. + +Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. + +### `attributes_metadata` + +Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py:669`) enforces the schema at load time and rejects: + +- Missing keys marked `required: true`. +- Keys present in `attributes` but not declared in `attributes_metadata` (typo / drift guard). +- Values that violate a declared `enum`. +- Values whose Python type does not match a declared `int`, `float` or `bool`. + +The runtime type check **only** covers `int`, `float` and `bool`. For `str`, `list_str` and `list_dict` the type is documentation-only — non-conforming values won't fail validation. If `attributes_metadata` is omitted, **no per-requirement validation runs at all**. + +```json +"attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { "csv": true, "ocsf": true } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { "csv": true, "ocsf": true } + } +] +``` + +Per attribute: + +- `key` (required): attribute name as it will appear in `requirement.attributes`. +- `label`: human-readable label used in CSV headers and PDF. +- `type`: one of `str`, `int`, `float`, `bool`, `list_str`, `list_dict`. Defaults to `str`. +- `enum`: optional list of allowed values; non-conforming values are rejected at load time. +- `required`: if `true`, every requirement must include this key with a non-null value. +- `enum_display` / `enum_order`: optional per-enum-value visual metadata (label, abbreviation, color, icon) and explicit ordering for PDF rendering. +- `output_formats`: `{ "csv": , "ocsf": }` — toggles inclusion in each output format. Both default to `true`. + +### `outputs` + +Optional. Controls how the framework is rendered in the console table and in the generated PDF report. Skipping it falls back to sensible defaults. + +```json +"outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ "ICT Risk Management", "ICT-Related Incident Reporting", "..." ], + "section_short_names": { "ICT Risk Management": "ICT Risk Mgmt" }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { "only_failed": true, "include_manual": false } + } +} +``` + +`table_config.group_by` must reference an attribute key declared in `attributes_metadata`. The same applies to `pdf_config.group_by_field` and to every `charts[].group_by`. + +For frameworks with weighted scoring (e.g. ThreatScore) declare `pdf_config.scoring` with `risk_field` / `weight_field` / `risk_boost_factor`. For column splitting (e.g. CIS Level 1 vs Level 2) use `table_config.split_by`. + +### `requirements` + +```json +"requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. ...", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled" + ], + "azure": [], + "gcp": [] + } + } +] +``` + +Per requirement: + +- `id` (required): unique identifier within the framework. +- `description` (required): the requirement text as authored by the framework. +- `name`: short title shown alongside the id. +- `attributes`: flat dict; keys must conform to `attributes_metadata`. +- `checks`: dict keyed by provider name (the same lowercase keys listed in the previous section). Each value is a list of Prowler check names that evidence this requirement for that provider. The list **may be empty** and the dict itself defaults to `{}` if omitted; either way the requirement is still loaded and listed by `--list-compliance-requirements`, it just has zero checks to execute. Note: there is **no automatic check-existence validation** at load time — referencing a non-existent check name will silently produce a requirement with no findings. Validate this yourself (see "Validating Your Framework" below). + +For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model). + +### Multi-provider frameworks + +A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict. + +When extending an existing universal framework with a new provider, the only change required is editing `requirement.checks`: + +```diff + "checks": { + "aws": ["iam_avoid_root_usage", "iam_no_root_access_key"], ++ "azure": ["entra_policy_ensure_mfa_for_admin_roles"] + } +``` + +No code changes, no new file, no registration step. + +## Legacy Provider-Specific Compliance Framework + +The legacy schema is still fully supported and remains the format used by most frameworks shipped today (CIS, NIST, ISO 27001, FedRAMP, PCI DSS, GDPR, HIPAA, ENS, etc.). It binds a framework to a single provider and validates each requirement against a framework-specific Pydantic attribute class. + +The legacy schema spans **four layers** — a complete contribution must touch every layer that applies: + +- **Layer 1 — Schema validation:** the Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape. +- **Layer 2 — JSON catalog:** the framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks. +- **Layer 3 — Output formatter:** the Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table. +- **Layer 4 — Output dispatchers:** the dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier. + +The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions. + +### Directory structure and file naming Compliance frameworks live at: @@ -46,8 +234,8 @@ prowler/compliance//__.json The filename conventions are: - All lowercase, words separated with underscores. -- `` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`. -- `` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`. +- `` is a supported provider identifier (same lowercase list as the universal section above). +- `` is optional but recommended. Omit only when the framework has no versioning (e.g. `ccc_aws.json`). - The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`. Examples: @@ -62,48 +250,50 @@ The output formatter directory mirrors the framework name: ``` prowler/lib/outputs/compliance// -├── .py # CLI summary-table dispatcher +├── .py # CLI summary-table dispatcher ├── _.py # Per-provider transformer class ├── models.py # Pydantic CSV row model └── __init__.py ``` -## JSON Schema Reference +### JSON schema reference -Every compliance file is a JSON document with the following top-level keys. +Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py:329`). | Field | Type | Required | Description | |---|---|---|---| | `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. | | `Name` | string | Yes | Human-readable framework name displayed by Prowler App. | -| `Version` | string | Yes | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). | +| `Version` | string | Yes (recommended) | Framework version, e.g. `2.0`. See [Version Handling](#version-handling). | | `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. | | `Description` | string | Yes | Short description of the framework's scope and purpose. | | `Requirements` | array | Yes | List of [requirement objects](#requirement-object). | -### Requirement Object +#### Requirement Object Each entry in `Requirements` describes one control or requirement. | Field | Type | Required | Description | |---|---|---|---| | `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. | -| `Name` | string | No | Optional human-readable name used by frameworks that distinguish control name from description, such as NIST. | +| `Name` | string | No | Optional human-readable name (frameworks like NIST distinguish control name from description). | | `Description` | string | Yes | Verbatim description from the source framework. | | `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. | | `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. | -### Attribute Objects +#### Attribute Objects -Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below. +`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py:293`). Pydantic v1 tries each member of the union in declaration order and falls back to `Generic_Compliance_Requirement_Attribute` (the last entry) when nothing else matches — so a brand-new shape that doesn't match any existing class will silently be accepted as Generic, losing its specific fields. -#### CIS_Requirement_Attribute +As of today, the registered attribute classes are: `CIS_Requirement_Attribute`, `ENS_Requirement_Attribute`, `ASDEssentialEight_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `AWS_Well_Architected_Requirement_Attribute`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `CCC_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`, and `Generic_Compliance_Requirement_Attribute` (fallback). MITRE-style frameworks use the separate `Mitre_Requirement` model with `Tactics` / `SubTechniques` / `Platforms` / `TechniqueURL` at the requirement top level. The most common shapes are summarized below. + +##### CIS_Requirement_Attribute Used by every CIS benchmark. | Field | Type | Required | Notes | |---|---|---|---| -| `Section` | string | Yes | Top-level section, for example `1 Identity and Access Management`. | +| `Section` | string | Yes | Top-level section, e.g. `1 Identity and Access Management`. | | `SubSection` | string | No | Optional second-level grouping. | | `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. | | `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. | @@ -116,7 +306,7 @@ Used by every CIS benchmark. | `DefaultValue` | string | No | Default configuration value, when relevant. | | `References` | string | Yes | Colon-separated list of reference URLs. | -#### ENS_Requirement_Attribute +##### ENS_Requirement_Attribute Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. @@ -132,13 +322,13 @@ Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. | `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). | | `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. | -#### CCC_Requirement_Attribute +##### CCC_Requirement_Attribute Used by the Common Cloud Controls Catalog. | Field | Type | Required | Notes | |---|---|---|---| -| `FamilyName` | string | Yes | Control family, for example `Data`. | +| `FamilyName` | string | Yes | Control family, e.g. `Data`. | | `FamilyDescription` | string | Yes | Description of the family. | | `Section` | string | Yes | Section title. | | `SubSection` | string | Yes | Subsection title, or empty string. | @@ -148,9 +338,9 @@ Used by the Common Cloud Controls Catalog. | `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | | `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | -#### Generic_Compliance_Requirement_Attribute +##### Generic_Compliance_Requirement_Attribute -The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA). +The fallback attribute model used when no framework-specific schema applies (e.g. NIST 800-53, PCI DSS, GDPR, HIPAA). It is **always the last** element of the `Compliance_Requirement.Attributes` Union; that ordering is load-bearing. | Field | Type | Required | Notes | |---|---|---|---| @@ -158,17 +348,17 @@ The fallback attribute model used when no framework-specific schema applies (for | `Section` | string | No | Section name. | | `SubSection` | string | No | Subsection name. | | `SubGroup` | string | No | Subgroup name. | -| `Service` | string | No | Affected service, for example `aws`, `iam`. | +| `Service` | string | No | Affected service, e.g. `iam`. | | `Type` | string | No | Control type. | | `Comment` | string | No | Free-form comment. | -Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets. +For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`) consult `prowler/lib/check/compliance_models.py` for the full field sets. -The `Attributes` field is a Pydantic `Union`. The generic attribute model must remain the last element of that Union, otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. +The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`. -## Minimal Working Example +#### Minimal working example The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity. @@ -214,26 +404,26 @@ The following snippet is a complete, valid framework file named `my_framework_1. } ``` -## Mapping Checks to Requirements +### Mapping checks to requirements Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control. -- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count, so omitting an unmappable control inflates coverage and misrepresents the framework. -- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file. +- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count. +- List every check by its canonical identifier — the value of `CheckID` inside the check's `.metadata.json` file. - One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope. -- Leave `Checks` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not. +- Leave `Checks` (legacy) or `checks.` (universal) as an empty array when the requirement cannot be automated. The requirement still appears in the report and contributes to the total. - Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure. -- Avoid referencing checks from a different provider. A compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. +- Avoid referencing checks from a different provider. A legacy compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. -To discover available checks, run: +To discover available checks: ```bash uv run python prowler-cli.py --list-checks ``` -## Supporting Multiple Providers +### Supporting multiple providers (legacy) -Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider: +The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider: ``` prowler/compliance/aws/cis_2.0_aws.json @@ -241,15 +431,15 @@ prowler/compliance/azure/cis_2.0_azure.json prowler/compliance/gcp/cis_2.0_gcp.json ``` -Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them, and change only the `Provider`, `Checks`, and provider-specific metadata. +Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them; change only the `Provider`, `Checks`, and provider-specific metadata. The CIS output formatter already supports every provider listed above. -The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). +For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). -## Output Formatter +### Output formatter -Prowler renders every compliance framework in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. +Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema. -For a new framework named `my_framework`, create: +For a new legacy framework named `my_framework`, create: ``` prowler/lib/outputs/compliance/my_framework/ @@ -259,19 +449,19 @@ prowler/lib/outputs/compliance/my_framework/ └── models.py # CSV row Pydantic model ``` -### Step 1 – Define the CSV Row Model +#### Step 1 — Define the CSV row model In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`. -### Step 2 – Implement the Transformer Class +#### Step 2 — Implement the transformer In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`. -### Step 3 – Add the Summary-Table Dispatcher +#### Step 3 — Add the summary-table dispatcher In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`. -### Step 4 – Register the Framework in the Dispatchers +#### Step 4 — Register the framework in the dispatchers - Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`. - Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan. @@ -280,49 +470,94 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema. -## Version Handling +### Legacy-to-universal adapter + +At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py:819`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically. + +Loader-error behaviour differs between the two entry points: + +- `load_compliance_framework()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py:464`). +- `load_compliance_framework_universal()` is more lenient — it logs the error and returns `None`, so `get_bulk_compliance_frameworks_universal()` simply skips the broken file and keeps loading the rest. + +## Version handling Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`. -- Always set `Version` to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`). +- Always set `Version` (or `version` for universal frameworks) to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`, `2022/2554`). - When the source catalog has no version, use the first year of adoption or the release date. -- Make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version. +- For **legacy** files, make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version. -## Validating the Framework Locally +## Validating Your Framework -Follow the steps below before opening a pull request. +Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists. -### 1. Run the Compliance Model Validator +### 1. Schema validation + +For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora.json` that key is `dora`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. + +```python +from prowler.lib.check.compliance_models import ( + load_compliance_framework_universal, + get_bulk_compliance_frameworks_universal, +) + +fw = load_compliance_framework_universal("prowler/compliance/.json") +assert fw is not None, "load returned None — check the logs for the validation error" +print(fw.framework, len(fw.requirements), fw.get_providers()) + +bulk = get_bulk_compliance_frameworks_universal("aws") +assert "" in bulk +``` + +### 2. Check existence cross-check + +There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory: + +```python +import os +real = set() +for svc in os.listdir("prowler/providers/aws/services"): + svc_path = f"prowler/providers/aws/services/{svc}" + if not os.path.isdir(svc_path): + continue + for entry in os.listdir(svc_path): + if os.path.isfile(f"{svc_path}/{entry}/{entry}.metadata.json"): + real.add(entry) + +referenced = {c for r in fw.requirements for c in r.checks.get("aws", [])} +missing = referenced - real +assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}" +``` + +### 3. CLI smoke test ```bash uv run python prowler-cli.py --list-compliance ``` -The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model. - -### 2. Run a Scan Filtered by the Framework +The framework must appear in the output. A validation error indicates a schema mismatch. ```bash uv run python prowler-cli.py \ - --compliance __ \ + --compliance \ --log-level ERROR ``` Verify that: - Prowler produces a CSV file under `output/compliance/` with the expected name. -- The CLI summary table lists every section in the framework. +- The CLI summary table lists every section / pillar of the framework. - Findings roll up under the expected requirements. -### 3. Inspect the CSV Output +### 4. Inspect the CSV output Open the generated CSV and confirm: -- All columns defined in `models.py` appear. -- Every requirement has at least one row per scanned resource. -- Values such as `Requirements_Attributes_Section` reflect the JSON content. +- All columns defined in `models.py` (legacy) or in `attributes_metadata` (universal) appear. +- Every requirement has at least one row per scanned resource (when there are findings). +- Attribute values such as `Requirements_Attributes_Section` reflect the JSON content. -### 4. Verify the Framework in Prowler App +### 5. Verify the framework in Prowler App Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly. @@ -331,7 +566,7 @@ Launch Prowler App locally (`docker compose up` from the repository root) and ru Compliance contributions require two layers of tests. - **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model. -- **Output tests** exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows. +- **Output tests** (legacy frameworks only) exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows. Run the suite with: @@ -342,7 +577,20 @@ uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing). -## Submitting the Pull Request +## Running and listing your framework + +Once the file is in place, the CLI auto-discovers it: + +```sh +prowler --list-compliance # framework appears in the list +prowler --compliance --list-checks +prowler --compliance # full scan + compliance report +prowler --compliance --list-compliance-requirements +``` + +For end-user-facing tutorials (recommended for high-profile frameworks), add a dedicated page under `docs/user-guide/compliance/tutorials/` and register it in the `"Compliance"` group of `docs/docs.json`. See `docs/user-guide/compliance/tutorials/threatscore.mdx` as a reference. + +## Submitting the pull request Before opening the pull request: @@ -352,28 +600,31 @@ Before opening the pull request: uv run pytest -n auto ``` 2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers. -3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`. +3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`. 4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`. ## Troubleshooting The following issues are the most common when contributing a compliance framework. -- **`ValidationError: field required` during scan.** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`. -- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON. -- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. -- **CLI summary table is empty but the CSV is populated.** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key. -- **CSV file is missing after the scan.** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`. -- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm. +- **`ValidationError: field required` during scan (legacy).** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`. +- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values (legacy).** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Keep the generic model in the last Union position and ensure every required field is present in the JSON. +- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py:669` rejected the file. The error message lists each offending requirement; common causes are unknown attribute keys (typo or missing entry in `attributes_metadata`), enum violations, or missing required keys. +- **`--compliance` filter does not find the framework.** For legacy: the filename does not match `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. For universal: the file is not at the top level of `prowler/compliance/` or it loaded as `None` (check logs for the validation error). +- **CLI summary table is empty but the CSV is populated (legacy).** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key. +- **CSV file is missing after the scan (legacy).** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`. +- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm, or run the check-existence cross-check from "Validating Your Framework". -## Reference Examples +## Reference examples Use the following files as templates when modeling a new contribution. -- `prowler/compliance/aws/cis_2.0_aws.json` – CIS attribute shape. -- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` – Generic attribute shape. -- `prowler/compliance/aws/ccc_aws.json` – CCC attribute shape. -- `prowler/compliance/azure/ens_rd2022_azure.json` – ENS attribute shape. -- `prowler/lib/check/compliance_models.py` – Canonical Pydantic schemas. -- `prowler/lib/outputs/compliance/cis/` – Reference implementation of a multi-provider output formatter. -- `prowler/lib/outputs/compliance/generic/` – Reference implementation of a generic output formatter. +- `prowler/compliance/dora.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). +- `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. +- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. +- `prowler/compliance/aws/ccc_aws.json` — legacy CCC attribute shape. +- `prowler/compliance/azure/ens_rd2022_azure.json` — legacy ENS attribute shape. +- `prowler/lib/check/compliance_models.py` — canonical Pydantic schemas for both formats. +- `prowler/lib/outputs/compliance/cis/` — reference implementation of a multi-provider legacy output formatter. +- `prowler/lib/outputs/compliance/generic/` — reference implementation of a legacy generic output formatter. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4f89a62992..d16f24ca99 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) --- diff --git a/prowler/__main__.py b/prowler/__main__.py index a9d794c2d6..ae1851bb17 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -85,11 +85,6 @@ from prowler.lib.outputs.compliance.compliance import ( display_compliance_table, process_universal_compliance_frameworks, ) -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -806,18 +801,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5) c5.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_aws": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_aws = AWSCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_aws) - csa_ccm_4_0_aws.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -921,18 +904,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_azure) c5_azure.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_azure": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_azure = AzureCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_azure) - csa_ccm_4_0_azure.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1036,18 +1007,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_gcp) c5_gcp.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_gcp": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_gcp = GCPCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_gcp) - csa_ccm_4_0_gcp.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1282,18 +1241,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_oraclecloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_oraclecloud = OracleCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_oraclecloud) - csa_ccm_4_0_oraclecloud.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1322,18 +1269,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_alibabacloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_alibabacloud = AlibabaCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_alibabacloud) - csa_ccm_4_0_alibabacloud.batch_write_data_to_file() elif compliance_name == "prowler_threatscore_alibabacloud": filename = ( f"{output_options.output_directory}/compliance/" diff --git a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json b/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json deleted file mode 100644 index 060b6e819e..0000000000 --- a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json +++ /dev/null @@ -1,7305 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "alibabacloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled", - "rds_instance_ssl_enabled", - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled", - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_logstore_retention_period", - "rds_instance_sql_audit_retention" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist", - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_lowercase", - "ram_password_policy_uppercase", - "ram_password_policy_number", - "ram_password_policy_symbol", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age", - "ram_password_policy_max_login_attempts" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused", - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles", - "ram_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_no_root_access_key", - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges", - "cs_kubernetes_rbac_enabled" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ecs_securitygroup_restrict_ssh_internet", - "ecs_securitygroup_restrict_rdp_internet" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied", - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cs_kubernetes_network_policy_enabled", - "cs_kubernetes_private_cluster_enabled", - "ecs_instance_no_legacy_network" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_cloud_firewall_changes_alert_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible", - "sls_logstore_retention_period" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk", - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled", - "sls_ram_role_changes_alert_enabled", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_oss_bucket_policy_changes_alert_enabled", - "sls_oss_permission_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled", - "oss_bucket_logging_enabled", - "rds_instance_sql_audit_enabled", - "cs_kubernetes_log_service_enabled", - "rds_instance_postgresql_log_connections_enabled", - "rds_instance_postgresql_log_disconnections_enabled", - "rds_instance_postgresql_log_duration_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "sls_customer_created_cmk_changes_alert_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled", - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/aws/csa_ccm_4.0_aws.json b/prowler/compliance/aws/csa_ccm_4.0_aws.json deleted file mode 100644 index 98d87112c9..0000000000 --- a/prowler/compliance/aws/csa_ccm_4.0_aws.json +++ /dev/null @@ -1,7617 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "AWS", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_source_repo_url_no_sensitive_credentials", - "codebuild_project_no_secrets_in_variables" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "backup_plans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_protected_by_backup_plan", - "ec2_ebs_volume_snapshots_exists", - "ec2_ebs_volume_protected_by_backup_plan", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_protected_by_backup_plan", - "rds_cluster_protected_by_backup_plan", - "redshift_cluster_automated_snapshot", - "s3_bucket_object_versioning", - "documentdb_cluster_backup_enabled", - "neptune_cluster_backup_enabled", - "elasticache_redis_cluster_backup_enabled", - "fsx_file_system_copy_tags_to_backups_enabled", - "lightsail_instance_automated_snapshots", - "dlm_ebs_snapshot_lifecycle_policy_exists" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "drs_job_exist", - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_multi_az", - "rds_cluster_multi_az", - "elbv2_is_in_multiple_az", - "elb_is_in_multiple_az", - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "ec2_ebs_volume_protected_by_backup_plan", - "elasticache_redis_cluster_multi_az_enabled", - "elasticache_redis_cluster_automatic_failover_enabled", - "opensearch_service_domains_fault_tolerant_data_nodes", - "opensearch_service_domains_fault_tolerant_master_nodes", - "dynamodb_accelerator_cluster_multi_az", - "documentdb_cluster_multi_az_enabled", - "neptune_cluster_multi_az", - "efs_multi_az_enabled", - "vpc_vpn_connection_tunnels_up", - "directconnect_connection_redundancy", - "directconnect_virtual_interface_redundancy", - "networkfirewall_multi_az", - "vpc_endpoint_multi_az_enabled", - "awslambda_function_vpc_multi_az", - "redshift_cluster_multi_az_enabled" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_log_file_validation_enabled", - "s3_bucket_object_lock", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "servicecatalog_portfolio_shared_within_organization_only" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "guardduty_is_enabled", - "securityhub_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "ec2_ebs_snapshots_encrypted", - "s3_bucket_default_encryption", - "s3_bucket_kms_encryption", - "s3_bucket_secure_transport_policy", - "rds_instance_storage_encrypted", - "rds_cluster_storage_encrypted", - "rds_instance_transport_encrypted", - "rds_snapshots_encrypted", - "efs_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_accelerator_cluster_in_transit_encryption_enabled", - "kinesis_stream_encrypted_at_rest", - "firehose_stream_encrypted_at_rest", - "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "opensearch_service_domains_encryption_at_rest_enabled", - "opensearch_service_domains_node_to_node_encryption_enabled", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_encrypted_at_rest", - "redshift_cluster_in_transit_encryption_enabled", - "documentdb_cluster_storage_encrypted", - "neptune_cluster_storage_encrypted", - "neptune_cluster_snapshot_encrypted", - "elasticache_redis_cluster_rest_encryption_enabled", - "elasticache_redis_cluster_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "kafka_cluster_encryption_at_rest_uses_cmk", - "kafka_connector_in_transit_encryption_enabled", - "dms_endpoint_ssl_enabled", - "dms_endpoint_redis_in_transit_encryption_enabled", - "elb_ssl_listeners", - "elbv2_ssl_listeners", - "elbv2_insecure_ssl_ciphers", - "elbv2_nlb_tls_termination_enabled", - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - "cloudfront_distributions_custom_ssl_certificate", - "transfer_server_in_transit_encryption_enabled", - "sagemaker_notebook_instance_encryption_enabled", - "sagemaker_training_jobs_volume_and_output_encryption_enabled", - "workspaces_volume_encryption_enabled", - "storagegateway_fileshare_encryption_enabled", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "athena_workgroup_encryption", - "glue_data_catalogs_connection_passwords_encryption_enabled", - "glue_data_catalogs_metadata_encryption_enabled", - "glue_etl_jobs_amazon_s3_encryption_enabled", - "glue_etl_jobs_cloudwatch_logs_encryption_enabled", - "glue_etl_jobs_job_bookmark_encryption_enabled", - "glue_development_endpoints_s3_encryption_enabled", - "glue_development_endpoints_cloudwatch_logs_encryption_enabled", - "glue_development_endpoints_job_bookmark_encryption_enabled", - "glue_ml_transform_encrypted_at_rest", - "bedrock_model_invocation_logs_encryption_enabled", - "bedrock_prompt_encrypted_with_cmk", - "codebuild_project_s3_logs_encrypted", - "codebuild_report_group_export_encrypted" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "acm_certificates_with_secure_key_algorithms", - "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers", - "cloudfront_distributions_using_deprecated_ssl_protocols" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used", - "s3_bucket_kms_encryption", - "dynamodb_tables_kms_cmk_encryption_enabled", - "kms_key_not_publicly_accessible", - "kms_cmk_not_multi_region" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "iam_rotate_access_key_90_days", - "secretsmanager_automatic_rotation_enabled", - "secretsmanager_secret_rotated_periodically" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_not_deleted_unintentionally" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "resourceexplorer2_indexes_found" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "dlm_ebs_snapshot_lifecycle_policy_exists", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_default_encryption", - "s3_account_level_public_access_blocks", - "s3_bucket_level_public_access_block", - "ec2_ebs_snapshot_account_block_public_access", - "rds_instance_no_public_access", - "rds_snapshots_public_access" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_in_transit_encryption_enabled", - "rds_instance_transport_encrypted", - "transfer_server_in_transit_encryption_enabled", - "kafka_cluster_mutual_tls_authentication_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "kinesis_stream_data_retention_period", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "s3_account_level_public_access_blocks", - "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "ec2_ebs_public_snapshot", - "rds_snapshots_public_access", - "rds_instance_no_public_access", - "ec2_ebs_volume_encryption", - "s3_bucket_default_encryption", - "rds_instance_storage_encrypted", - "secretsmanager_not_publicly_accessible", - "macie_is_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "guardduty_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_lowercase", - "iam_password_policy_uppercase", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_password_policy_lowercase", - "cognito_user_pool_password_policy_uppercase", - "cognito_user_pool_password_policy_number", - "cognito_user_pool_password_policy_symbol" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_two_active_access_key" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_securityaudit_role_created", - "iam_support_role_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_inline_policy_no_wildcard_marketplace_subscribe", - "iam_policy_no_wildcard_marketplace_subscribe", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_policy_allows_privilege_escalation", - "iam_inline_policy_allows_privilege_escalation", - "iam_no_custom_policy_permissive_role_assumption", - "iam_role_administratoraccess_policy", - "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy", - "iam_administrator_access_with_mfa" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_no_setup_initial_access_key" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_rotate_access_key_90_days", - "secretsmanager_secret_unused" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_role_administratoraccess_policy", - "iam_avoid_root_usage", - "iam_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_avoid_root_usage", - "iam_no_root_access_key", - "iam_role_cross_account_readonlyaccess_policy", - "iam_role_cross_service_confused_deputy_prevention", - "iam_inline_policy_allows_privilege_escalation", - "iam_policy_allows_privilege_escalation" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_bucket_requires_mfa_delete" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_user_mfa_enabled_console_access", - "iam_check_saml_providers_sts" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled", - "cognito_user_pool_mfa_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_temporary_password_expiration" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "apigateway_restapi_authorizers_enabled", - "apigatewayv2_api_authorizers_enabled", - "awslambda_function_not_publicly_accessible", - "awslambda_function_url_public", - "cognito_user_pool_waf_acl_attached" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", - "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_tcp_port_22", - "ec2_networkacl_allow_ingress_tcp_port_3389", - "ec2_securitygroup_allow_wide_open_public_ipv4", - "vpc_peering_routing_tables_with_least_privilege", - "vpc_subnet_no_public_ip_by_default" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_instance_imdsv2_enabled", - "ec2_instance_account_imdsv2_enabled", - "ec2_launch_template_imdsv2_required", - "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "ec2_securitygroup_default_restrict_traffic", - "vpc_subnet_separate_private_public", - "vpc_peering_routing_tables_with_least_privilege", - "ec2_instance_public_ip", - "awslambda_function_inside_vpc", - "sagemaker_notebook_instance_vpc_settings_configured", - "sagemaker_models_vpc_settings_configured", - "sagemaker_training_jobs_vpc_settings_configured" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "dms_endpoint_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "networkfirewall_in_all_vpc", - "networkfirewall_logging_enabled", - "networkfirewall_policy_rule_group_associated", - "wafv2_webacl_with_rules", - "wafv2_webacl_logging_enabled", - "elbv2_waf_acl_attached", - "cloudfront_distributions_using_waf", - "guardduty_is_enabled", - "shield_advanced_protection_in_cloudfront_distributions", - "shield_advanced_protection_in_internet_facing_load_balancers" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_bucket_requires_mfa_delete", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "s3_bucket_object_lock" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "guardduty_no_high_severity_findings" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_bedrock_logging_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "vpc_flow_logs_enabled", - "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "vpc_flow_logs_enabled", - "s3_bucket_server_access_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "cloudfront_distributions_logging_enabled", - "route53_public_hosted_zones_cloudwatch_logging_enabled", - "wafv2_webacl_logging_enabled", - "redshift_cluster_audit_logging", - "rds_cluster_integration_cloudwatch_logs", - "rds_instance_integration_cloudwatch_logs", - "opensearch_service_domains_audit_logging_enabled", - "eks_control_plane_logging_all_types_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "networkfirewall_logging_enabled", - "mq_broker_logging_enabled", - "documentdb_cluster_cloudwatch_log_export", - "neptune_cluster_integration_cloudwatch_logs", - "codebuild_project_logging_enabled", - "glue_etl_jobs_logging_enabled", - "stepfunctions_statemachine_logging_enabled", - "datasync_task_logging_enabled", - "ec2_client_vpn_endpoint_connection_logging_enabled", - "elasticbeanstalk_environment_cloudwatch_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "acm_certificates_expiration_check" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled_logging_management_events" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "account_maintain_current_contact_details", - "account_security_contact_information_is_registered", - "account_maintain_different_contact_details_to_security_billing_and_operations" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_ec2_malware_protection_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ssm_managed_compliant_patching", - "rds_instance_minor_version_upgrade_enabled", - "rds_cluster_minor_version_upgrade_enabled", - "redshift_cluster_automatic_upgrades", - "elasticbeanstalk_environment_managed_updates_enabled", - "dms_instance_minor_version_upgrade_enabled", - "elasticache_redis_cluster_auto_minor_version_upgrades", - "memorydb_cluster_auto_minor_version_upgrades", - "mq_broker_auto_minor_version_upgrades", - "opensearch_service_domains_updated_to_the_latest_service_software_version", - "kafka_cluster_uses_latest_version" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "inspector2_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "guardduty_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "workspaces_volume_encryption_enabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - } - ] -} diff --git a/prowler/compliance/azure/csa_ccm_4.0_azure.json b/prowler/compliance/azure/csa_ccm_4.0_azure.json deleted file mode 100644 index b4505ac089..0000000000 --- a/prowler/compliance/azure/csa_ccm_4.0_azure.json +++ /dev/null @@ -1,7548 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "Azure", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_auth_is_set_up", - "app_ftp_deployment_disabled", - "app_function_access_keys_configured", - "app_function_ftps_deployment_disabled", - "app_register_with_identity" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_server_is_on", - "vm_backup_enabled" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled", - "storage_geo_redundant_enabled", - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "iam_custom_role_has_permissions_to_administer_resource_locks", - "monitor_alert_create_policy_assignment", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_policy_assignment", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_delete_sqlserver_fr", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "databricks_workspace_cmk_encryption_enabled", - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_infrastructure_encryption_is_enabled", - "storage_secure_transfer_required_is_enabled", - "storage_smb_channel_encryption_with_secure_algorithm", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "keyvault_key_rotation_enabled", - "mysql_flexible_server_minimum_tls_version_12", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_smb_protocol_version_is_latest" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_key_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_key_rotation_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_recoverable" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "aisearch_service_not_publicly_accessible", - "aks_clusters_public_access_disabled", - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "sqlserver_unrestricted_inbound_access", - "storage_blob_public_access_level_is_disabled", - "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_http_is_redirected_to_https", - "app_ensure_using_http20", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_retention_90_days", - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "defender_ensure_defender_for_storage_is_on", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "sqlserver_unrestricted_inbound_access", - "storage_account_key_access_disabled", - "storage_blob_public_access_level_is_disabled", - "storage_cross_tenant_replication_disabled", - "storage_default_network_access_rule_is_denied", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "vm_ensure_attached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_policy_default_users_cannot_create_security_groups", - "entra_policy_ensure_default_user_cannot_create_apps", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "app_function_identity_without_admin_privileges", - "entra_policy_ensure_default_user_cannot_create_apps", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_restricts_user_consent_for_apps", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_key_rotation_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "entra_policy_guest_invite_only_for_admin_roles", - "entra_policy_guest_users_access_restrictions", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_user_with_vm_access_has_mfa", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "vm_jit_access_enabled" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "entra_trusted_named_locations_exists", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "postgresql_flexible_server_entra_id_authentication_enabled", - "sqlserver_azuread_administrator_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_not_publicly_accessible", - "entra_policy_user_consent_for_verified_apps", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "network_bastion_host_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "network_watcher_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_ensure_system_updates_are_applied", - "vm_ensure_using_managed_disks", - "vm_linux_enforce_ssh_authentication", - "vm_trusted_launch_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "app_function_not_publicly_accessible", - "app_function_vnet_integration_enabled", - "containerregistry_uses_private_link", - "cosmosdb_account_use_private_endpoints", - "databricks_workspace_vnet_injection_enabled", - "network_http_internet_access_restricted", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_iot_hub_defender_is_on", - "defender_ensure_wdatp_is_enabled", - "network_flow_log_captured_sent", - "network_watcher_enabled", - "sqlserver_microsoft_defender_enabled", - "vm_jit_access_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_create_update_security_solution", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_nsg", - "monitor_alert_delete_policy_assignment", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_alert_delete_sqlserver_fr", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "appinsights_ensure_is_configured", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "app_http_logs_enabled", - "appinsights_ensure_is_configured", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "mysql_flexible_server_audit_log_connection_activated", - "mysql_flexible_server_audit_log_enabled", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "postgresql_flexible_server_log_checkpoints_on", - "postgresql_flexible_server_log_connections_on", - "postgresql_flexible_server_log_disconnections_on", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_enabled", - "sqlserver_auditing_retention_90_days" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_rbac_key_expiration_set" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_additional_email_configured_with_a_security_contact", - "defender_ensure_notify_emails_to_owners" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_auto_provisioning_log_analytics_agent_vms_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_java_version_is_latest", - "app_ensure_php_version_is_latest", - "app_ensure_python_version_is_latest", - "app_function_latest_runtime_version", - "defender_ensure_system_updates_are_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_cosmosdb_is_on", - "defender_ensure_defender_for_os_relational_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_defender_for_sql_servers_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_emails_notifications_admins_enabled", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "sqlserver_microsoft_defender_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "storage_infrastructure_encryption_is_enabled", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled" - ] - } - ] -} diff --git a/prowler/compliance/dora.json b/prowler/compliance/dora.json new file mode 100644 index 0000000000..2378a307c6 --- /dev/null +++ b/prowler/compliance/dora.json @@ -0,0 +1,597 @@ +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)", + "version": "2022/2554", + "description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.", + "icon": "dora", + "attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ArticleTitle", + "label": "Article Title", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "section_short_names": { + "ICT Risk Management": "ICT Risk Mgmt", + "ICT-Related Incident Reporting": "Incident Reporting", + "Digital Operational Resilience Testing": "Resilience Testing", + "ICT Third-Party Risk Management": "Third-Party Risk", + "Information Sharing": "Info Sharing" + }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by DORA Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_credentials_management_enabled", + "iam_password_policy_minimum_length_14", + "iam_password_policy_lowercase", + "iam_password_policy_uppercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_securityaudit_role_created", + "iam_support_role_created", + "organizations_account_part_of_organizations", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled" + ] + } + }, + { + "id": "DORA-Art6", + "name": "ICT risk management framework", + "description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 6", + "ArticleTitle": "ICT risk management framework" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "config_recorder_using_aws_service_role", + "securityhub_enabled", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "organizations_delegated_administrators", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ] + } + }, + { + "id": "DORA-Art7", + "name": "ICT systems, protocols and tools", + "description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 7", + "ArticleTitle": "ICT systems, protocols and tools" + }, + "checks": { + "aws": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "acm_certificates_expiration_check", + "ec2_ebs_default_encryption", + "kms_cmk_rotation_enabled", + "s3_bucket_secure_transport_policy", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "vpc_subnet_separate_private_public", + "vpc_subnet_no_public_ip_by_default", + "elb_insecure_ssl_ciphers", + "elbv2_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_ssl_listeners", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "cloudfront_distributions_https_enabled", + "rds_instance_transport_encrypted" + ] + } + }, + { + "id": "DORA-Art8", + "name": "Identification", + "description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 8", + "ArticleTitle": "Identification" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "ec2_securitygroup_not_used", + "ec2_elastic_ip_unassigned", + "ec2_networkacl_unused", + "secretsmanager_secret_unused" + ] + } + }, + { + "id": "DORA-Art9", + "name": "Protection and prevention", + "description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 9", + "ArticleTitle": "Protection and prevention" + }, + "checks": { + "aws": [ + "kms_key_not_publicly_accessible", + "ec2_ebs_volume_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_write_acl", + "s3_bucket_public_list_acl", + "s3_bucket_acl_prohibited", + "s3_access_point_public_access_block", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "secretsmanager_not_publicly_accessible", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_automatic_rotation_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + "efs_encryption_at_rest_enabled", + "awslambda_function_not_publicly_accessible" + ] + } + }, + { + "id": "DORA-Art10", + "name": "Detection", + "description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 10", + "ArticleTitle": "Detection" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "guardduty_ec2_malware_protection_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled", + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "securityhub_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_insights_exist", + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "ec2_elastic_ip_shodan" + ] + } + }, + { + "id": "DORA-Art11", + "name": "Response and recovery", + "description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 11", + "ArticleTitle": "Response and recovery" + }, + "checks": { + "aws": [ + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured", + "eventbridge_global_endpoint_event_replication_enabled", + "sns_subscription_not_using_http_endpoints", + "backup_plans_exist", + "backup_vaults_exist", + "rds_instance_critical_event_subscription", + "rds_cluster_critical_event_subscription" + ] + } + }, + { + "id": "DORA-Art12", + "name": "Backup policies and procedures, restoration and recovery procedures and methods", + "description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 12", + "ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "rds_instance_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_instance_multi_az", + "rds_cluster_multi_az", + "rds_cluster_backtrack_enabled", + "rds_instance_deletion_protection", + "rds_cluster_deletion_protection", + "rds_snapshots_encrypted", + "s3_bucket_object_versioning", + "s3_bucket_object_lock", + "s3_bucket_cross_region_replication", + "s3_bucket_no_mfa_delete", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_deletion_protection_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "autoscaling_group_multiple_az", + "elb_is_in_multiple_az", + "elbv2_is_in_multiple_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "dynamodb_table_protected_by_backup_plan" + ] + } + }, + { + "id": "DORA-Art13", + "name": "Learning and evolving", + "description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 13", + "ArticleTitle": "Learning and evolving" + }, + "checks": { + "aws": [ + "securityhub_enabled", + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_insights_exist" + ] + } + }, + { + "id": "DORA-Art14", + "name": "Communication", + "description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 14", + "ArticleTitle": "Communication" + }, + "checks": { + "aws": [ + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "sns_subscription_not_using_http_endpoints", + "eventbridge_bus_exposed", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured" + ] + } + }, + { + "id": "DORA-Art17", + "name": "ICT-related incident management process", + "description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 17", + "ArticleTitle": "ICT-related incident management process" + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_bedrock_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_no_secrets_in_logs", + "cloudwatch_log_group_not_publicly_accessible", + "vpc_flow_logs_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "cloudfront_distributions_logging_enabled", + "s3_bucket_server_access_logging_enabled" + ] + } + }, + { + "id": "DORA-Art18", + "name": "Classification of ICT-related incidents and cyber threats", + "description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 18", + "ArticleTitle": "Classification of ICT-related incidents and cyber threats" + }, + "checks": { + "aws": [ + "guardduty_no_high_severity_findings", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions", + "securityhub_enabled", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation" + ] + } + }, + { + "id": "DORA-Art19", + "name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats", + "description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 19", + "ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats" + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "sns_subscription_not_using_http_endpoints" + ] + } + }, + { + "id": "DORA-Art24", + "name": "General requirements for the performance of digital operational resilience testing", + "description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 24", + "ArticleTitle": "General requirements for the performance of digital operational resilience testing" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "securityhub_enabled", + "ec2_instance_managed_by_ssm", + "ec2_instance_with_outdated_ami", + "ssm_managed_compliant_patching" + ] + } + }, + { + "id": "DORA-Art25", + "name": "Testing of ICT tools and systems", + "description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 25", + "ArticleTitle": "Testing of ICT tools and systems" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "config_recorder_all_regions_enabled", + "ec2_instance_with_outdated_ami", + "ec2_instance_managed_by_ssm", + "ec2_instance_paravirtual_type", + "rds_instance_deprecated_engine_version", + "acm_certificates_expiration_check", + "rds_instance_certificate_expiration", + "iam_no_expired_server_certificates_stored", + "ssm_managed_compliant_patching" + ] + } + }, + { + "id": "DORA-Art28", + "name": "General principles (ICT third-party risk)", + "description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 28", + "ArticleTitle": "General principles (ICT third-party risk)" + }, + "checks": { + "aws": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "iam_no_custom_policy_permissive_role_assumption", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "s3_bucket_cross_account_access", + "dynamodb_table_cross_account_access", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_cross_account_sharing_disabled", + "organizations_delegated_administrators", + "organizations_account_part_of_organizations", + "organizations_scp_check_deny_regions", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "awslambda_function_using_cross_account_layers" + ] + } + }, + { + "id": "DORA-Art30", + "name": "Key contractual provisions", + "description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 30", + "ArticleTitle": "Key contractual provisions" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_administrator_access_with_mfa", + "iam_policy_attached_only_to_group_or_roles", + "accessanalyzer_enabled" + ] + } + }, + { + "id": "DORA-Art45", + "name": "Information-sharing arrangements on cyber threat information and intelligence", + "description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.", + "attributes": { + "Pillar": "Information Sharing", + "Article": "Article 45", + "ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_centrally_managed", + "securityhub_enabled", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "accessanalyzer_enabled_without_findings" + ] + } + } + ] +} diff --git a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json b/prowler/compliance/gcp/csa_ccm_4.0_gcp.json deleted file mode 100644 index 6623fe5eca..0000000000 --- a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json +++ /dev/null @@ -1,7386 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "GCP", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_versioning_enabled", - "cloudstorage_bucket_soft_delete_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_automatic_restart_enabled", - "compute_instance_group_autohealing_enabled", - "compute_instance_group_load_balancer_attached", - "compute_instance_group_multiple_zones", - "compute_instance_on_host_maintenance_migrate" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_encryption_with_csek_enabled", - "compute_instance_confidential_computing_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "cloudsql_instance_ssl_connections", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "iam_sa_user_managed_key_rotate_90_days", - "apikeys_key_rotated_in_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_public_access", - "compute_image_not_publicly_shared", - "compute_instance_public_ip", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled", - "cloudstorage_bucket_sufficient_retention_period" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "compute_instance_public_ip", - "compute_image_not_publicly_shared", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_role_sa_enforce_separation_of_duties", - "iam_role_kms_enforce_separation_of_duties", - "iam_no_service_roles_at_project_level" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "iam_sa_user_managed_key_rotate_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_2fa_enabled", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "cloudstorage_bucket_uniform_bucket_level_access", - "compute_instance_default_service_account_in_use_with_full_api_access", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_group_autohealing_enabled", - "compute_instance_group_load_balancer_attached", - "compute_instance_group_multiple_zones" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_ip_forwarding_is_enabled", - "compute_network_default_in_use", - "compute_network_dns_logging_enabled", - "compute_network_not_legacy", - "compute_subnet_flow_logs_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_shielded_vm_enabled", - "compute_project_os_login_enabled", - "compute_instance_serial_ports_in_use", - "compute_instance_block_project_wide_ssh_keys_disabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_private_ip_assignment", - "cloudstorage_uses_vpc_service_controls", - "compute_instance_public_ip", - "compute_network_default_in_use", - "compute_network_not_legacy" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_loadbalancer_logging_enabled", - "compute_public_address_shodan", - "dns_dnssec_disabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "iam_audit_logs_enabled", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "compute_subnet_flow_logs_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_postgres_enable_pgaudit_flag" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_uniform_bucket_level_access" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "compute_public_address_shodan", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_confidential_computing_enabled", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json b/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json deleted file mode 100644 index 300e32788d..0000000000 --- a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json +++ /dev/null @@ -1,7307 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "OracleCloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_versioning_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_user_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "compute_instance_in_transit_encryption_enabled", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "objectstorage_bucket_encrypted_with_cmk", - "database_autonomous_database_access_restricted", - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_service_level_admins_exist", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_permissions_limited" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "identity_no_resources_in_root_compartment", - "identity_non_root_compartment_exists" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_users_no_api_keys" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_tenancy_admin_users_no_api_keys", - "identity_iam_admins_cannot_update_tenancy_admins" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "network_vcn_subnet_flow_logs_enabled", - "network_default_security_list_restricts_traffic", - "network_security_group_ingress_from_internet_to_ssh_port", - "network_security_group_ingress_from_internet_to_rdp_port", - "network_security_list_ingress_from_internet_to_ssh_port", - "network_security_list_ingress_from_internet_to_rdp_port" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_legacy_metadata_endpoint_disabled", - "compute_instance_secure_boot_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "network_default_security_list_restricts_traffic", - "identity_non_root_compartment_exists", - "identity_no_resources_in_root_compartment" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists", - "events_rule_local_user_authentication" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_identity_provider_changes", - "events_rule_idp_group_mapping_changes", - "events_rule_local_user_authentication", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_user_changes", - "events_rule_vcn_changes", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index b6b2f1f81f..28611a68a1 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -10,7 +10,6 @@ from prowler.lib.outputs.compliance.cis.cis import get_cis_table from prowler.lib.outputs.compliance.compliance_check import ( # noqa: F401 - re-export for backward compatibility get_check_compliance, ) -from prowler.lib.outputs.compliance.csa.csa import get_csa_table from prowler.lib.outputs.compliance.ens.ens import get_ens_table from prowler.lib.outputs.compliance.generic.generic_table import ( get_generic_compliance_table, @@ -33,24 +32,28 @@ def process_universal_compliance_frameworks( output_filename: str, provider: str, generated_outputs: dict, + from_cli: bool = True, + is_last: bool = True, ) -> set: """Process universal compliance frameworks, generating CSV and OCSF outputs. For each framework in *input_compliance_frameworks* that exists in - *universal_frameworks* and has an outputs.table_config, this function - creates both a CSV (UniversalComplianceOutput) and an OCSF JSON - (OCSFComplianceOutput) file. OCSF is always generated regardless of + *universal_frameworks* and has an ``outputs.table_config``, this function + writes both a CSV (``UniversalComplianceOutput``) and an OCSF JSON + (``OCSFComplianceOutput``) file. OCSF is always generated regardless of the user's ``--output-formats`` flag. - The function is idempotent: it tracks already-created writers via - ``generated_outputs["compliance"]`` keyed by ``file_path``. If invoked - again for the same framework (e.g. once per streaming batch), it - reuses the existing writer instead of recreating it. This guarantees - one output writer per framework for the whole execution and keeps - the OCSF JSON array valid across multiple calls. + Streaming-aware: writers are tracked via ``generated_outputs["compliance"]`` + keyed by ``file_path``. On the first call per framework a new writer is + created and emits both findings and manual requirements; subsequent calls + reuse the writer, transform only the new ``finding_outputs`` (manual + requirements are not re-emitted), and append to the open file. Set + ``from_cli=False`` and ``is_last=False`` for intermediate batches; pass + ``is_last=True`` on the final batch to close the file (OCSF is also + finalized as a valid JSON array). - Returns the set of framework names that were processed so the caller - can remove them before entering the legacy per-provider output loop. + Returns the set of framework names processed so the caller can subtract + them from the legacy per-provider output loop. """ from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( OCSFComplianceOutput, @@ -65,6 +68,13 @@ def process_universal_compliance_frameworks( if isinstance(out, (UniversalComplianceOutput, OCSFComplianceOutput)) } + def _flush(writer, framework, label, is_new): + if not is_new: + writer._transform(finding_outputs, framework, label, include_manual=False) + writer.close_file = is_last + writer.batch_write_data_to_file() + writer._data.clear() + processed = set() for compliance_name in input_compliance_frameworks: if not ( @@ -75,37 +85,46 @@ def process_universal_compliance_frameworks( continue fw = universal_frameworks[compliance_name] + compliance_label = ( + fw.framework + "-" + fw.version if fw.version else fw.framework + ) # CSV output csv_path = ( f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.csv" ) - if csv_path not in existing_writers: - output = UniversalComplianceOutput( + csv_writer = existing_writers.get(csv_path) + csv_is_new = csv_writer is None + if csv_is_new: + csv_writer = UniversalComplianceOutput( findings=finding_outputs, framework=fw, file_path=csv_path, + from_cli=from_cli, provider=provider, ) - generated_outputs["compliance"].append(output) - existing_writers[csv_path] = output - output.batch_write_data_to_file() + generated_outputs["compliance"].append(csv_writer) + existing_writers[csv_path] = csv_writer + _flush(csv_writer, fw, compliance_label, csv_is_new) # OCSF output (always generated for universal frameworks) ocsf_path = ( f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.ocsf.json" ) - if ocsf_path not in existing_writers: - ocsf_output = OCSFComplianceOutput( + ocsf_writer = existing_writers.get(ocsf_path) + ocsf_is_new = ocsf_writer is None + if ocsf_is_new: + ocsf_writer = OCSFComplianceOutput( findings=finding_outputs, framework=fw, file_path=ocsf_path, + from_cli=from_cli, provider=provider, ) - generated_outputs["compliance"].append(ocsf_output) - existing_writers[ocsf_path] = ocsf_output - ocsf_output.batch_write_data_to_file() + generated_outputs["compliance"].append(ocsf_writer) + existing_writers[ocsf_path] = ocsf_writer + _flush(ocsf_writer, fw, compliance_label, ocsf_is_new) processed.add(compliance_name) @@ -206,15 +225,6 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif compliance_framework.startswith("csa_ccm_"): - get_csa_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) elif compliance_framework.startswith("c5_"): get_c5_table( findings, diff --git a/prowler/lib/outputs/compliance/csa/__init__.py b/prowler/lib/outputs/compliance/csa/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/prowler/lib/outputs/compliance/csa/csa.py b/prowler/lib/outputs/compliance/csa/csa.py deleted file mode 100644 index ab8a021a70..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa.py +++ /dev/null @@ -1,101 +0,0 @@ -from colorama import Fore, Style -from tabulate import tabulate - -from prowler.config.config import orange_color - - -def get_csa_table( - findings: list, - bulk_checks_metadata: dict, - compliance_framework: str, - output_filename: str, - output_directory: str, - compliance_overview: bool, -): - section_table = { - "Provider": [], - "Section": [], - "Status": [], - "Muted": [], - } - pass_count = [] - fail_count = [] - muted_count = [] - sections = {} - for index, finding in enumerate(findings): - check = bulk_checks_metadata[finding.check_metadata.CheckID] - check_compliances = check.Compliance - for compliance in check_compliances: - if ( - compliance.Framework == "CSA-CCM" - and compliance.Version in compliance_framework - ): - for requirement in compliance.Requirements: - for attribute in requirement.Attributes: - section = attribute.Section - - if section not in sections: - sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} - - if finding.muted: - if index not in muted_count: - muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: - fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: - pass_count.append(index) - sections[section]["PASS"] += 1 - - sections = dict(sorted(sections.items())) - for section in sections: - section_table["Provider"].append(compliance.Provider) - section_table["Section"].append(section) - if sections[section]["FAIL"] > 0: - section_table["Status"].append( - f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}" - ) - else: - if sections[section]["PASS"] > 0: - section_table["Status"].append( - f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}" - ) - else: - section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") - section_table["Muted"].append( - f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}" - ) - - if ( - len(fail_count) + len(pass_count) + len(muted_count) > 1 - ): # If there are no resources, don't print the compliance table - print( - f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:" - ) - total_findings_count = len(fail_count) + len(pass_count) + len(muted_count) - overview_table = [ - [ - f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}", - f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}", - f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", - ] - ] - print(tabulate(overview_table, tablefmt="rounded_grid")) - if not compliance_overview: - if len(fail_count) > 0 and len(section_table["Section"]) > 0: - print( - f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" - ) - print( - tabulate( - section_table, - tablefmt="rounded_grid", - headers="keys", - ) - ) - print(f"\nDetailed results of {compliance_framework.upper()} are in:") - print( - f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n" - ) diff --git a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py b/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py deleted file mode 100644 index e0867ab5f1..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AlibabaCloudCSAModel -from prowler.lib.outputs.finding import Finding - - -class AlibabaCloudCSA(ComplianceOutput): - """ - This class represents the Alibaba Cloud CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Alibaba Cloud CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Alibaba Cloud CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_aws.py b/prowler/lib/outputs/compliance/csa/csa_aws.py deleted file mode 100644 index 6309dbe287..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_aws.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AWSCSAModel -from prowler.lib.outputs.finding import Finding - - -class AWSCSA(ComplianceOutput): - """ - This class represents the AWS CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into AWS CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into AWS CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_azure.py b/prowler/lib/outputs/compliance/csa/csa_azure.py deleted file mode 100644 index cf9a1064e6..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_azure.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AzureCSAModel -from prowler.lib.outputs.finding import Finding - - -class AzureCSA(ComplianceOutput): - """ - This class represents the Azure CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Azure CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Azure CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=finding.provider, - Description=compliance.Description, - SubscriptionId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - SubscriptionId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_gcp.py b/prowler/lib/outputs/compliance/csa/csa_gcp.py deleted file mode 100644 index 4a829295db..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_gcp.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import GCPCSAModel -from prowler.lib.outputs.finding import Finding - - -class GCPCSA(ComplianceOutput): - """ - This class represents the GCP CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into GCP CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into GCP CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=finding.provider, - Description=compliance.Description, - ProjectId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - ProjectId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py b/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py deleted file mode 100644 index 107589c0f7..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import OracleCloudCSAModel -from prowler.lib.outputs.finding import Finding - - -class OracleCloudCSA(ComplianceOutput): - """ - This class represents the OracleCloud CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into OracleCloud CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into OracleCloud CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( - Provider=finding.provider, - Description=compliance.Description, - TenancyId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - TenancyId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/models.py b/prowler/lib/outputs/compliance/csa/models.py deleted file mode 100644 index 78c7384fc6..0000000000 --- a/prowler/lib/outputs/compliance/csa/models.py +++ /dev/null @@ -1,146 +0,0 @@ -from pydantic.v1 import BaseModel - - -class AWSCSAModel(BaseModel): - """ - AWSCSAModel generates a finding's output in CSV CSA format for AWS. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class GCPCSAModel(BaseModel): - """ - GCPCSAModel generates a finding's output in CSV CSA format for GCP. - """ - - Provider: str - Description: str - ProjectId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class OracleCloudCSAModel(BaseModel): - """ - OracleCloudCSAModel generates a finding's output in CSV CSA format for OracleCloud. - """ - - Provider: str - Description: str - TenancyId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AlibabaCloudCSAModel(BaseModel): - """ - AlibabaCloudCSAModel generates a finding's output in CSV CSA format for Alibaba Cloud. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AzureCSAModel(BaseModel): - """ - AzureCSAModel generates a finding's output in CSV CSA format for Azure. - """ - - Provider: str - Description: str - SubscriptionId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py index 2ce69412e1..2886f7e4d2 100644 --- a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -79,30 +79,43 @@ def _to_snake_case(name: str) -> str: return s.lower() -def _build_requirement_attrs(requirement, framework) -> dict: - """Build a dict with requirement attributes for the unmapped section. +def _build_requirement_attrs(requirement, framework): + """Build the requirement attributes payload for the unmapped section. - Keys are normalized to snake_case for OCSF consistency. - Only includes attributes whose AttributeMetadata has output_formats.ocsf=True. - When no metadata is declared, all attributes are included. + Keys are snake_cased and filtered by ``AttributeMetadata.output_formats.ocsf`` + when declared. MITRE-style attrs (``{"_raw_attributes": [...]}``) are + unwrapped into a list of per-entry dicts. """ - attrs = requirement.attributes - if not attrs: + requirement_attributes = requirement.attributes + if not requirement_attributes: return {} - # Build set of keys allowed for OCSF output metadata = framework.attributes_metadata - if metadata: - ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf} - else: - ocsf_keys = None # No metadata → include all + allowed_keys = ( + {entry.key for entry in metadata if entry.output_formats.ocsf} + if metadata + else None + ) - result = {} - for key, value in attrs.items(): - if ocsf_keys is not None and key not in ocsf_keys: - continue - result[_to_snake_case(key)] = value - return result + def _to_snake_case_dict(entry: dict) -> dict: + return { + _to_snake_case(key): value + for key, value in entry.items() + if allowed_keys is None or key in allowed_keys + } + + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + raw_entries = requirement_attributes.get("_raw_attributes") or [] + return [ + _to_snake_case_dict(entry) + for entry in raw_entries + if isinstance(entry, dict) + ] + + return _to_snake_case_dict(requirement_attributes) class OCSFComplianceOutput: @@ -147,7 +160,14 @@ class OCSFComplianceOutput: findings: List["Finding"], framework: ComplianceFramework, compliance_name: str, + include_manual: bool = True, ) -> None: + """Transform findings into OCSF ComplianceFinding events. + + Manual requirements are emitted only when ``include_manual=True``. The + caller must pass ``False`` for subsequent streaming batches so manual + events are not duplicated. + """ # Build check -> requirements map check_req_map = {} for req in framework.requirements: @@ -170,6 +190,9 @@ class OCSFComplianceOutput: if cf: self._data.append(cf) + if not include_manual: + return + # Manual requirements (no checks or empty for current provider) for req in framework.requirements: checks = req.checks diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 5f99f05755..a3cdb1389a 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -198,8 +198,15 @@ class UniversalComplianceOutput: findings: list["Finding"], framework: ComplianceFramework, compliance_name: str, + include_manual: bool = True, ) -> None: - """Transform findings into universal compliance CSV rows.""" + """Transform findings into universal compliance CSV rows. + + Manual requirements (no checks or empty for current provider) are + emitted only when ``include_manual=True``. When the writer is reused + across streaming batches, the caller should pass ``False`` after the + first batch so manual rows are not duplicated. + """ # Build check -> requirements map (filtered by provider for dict checks) check_req_map = {} for req in framework.requirements: @@ -228,6 +235,9 @@ class UniversalComplianceOutput: except Exception as e: logger.debug(f"Skipping row for {req.id}: {e}") + if not include_manual: + return + # Manual requirements (no checks or empty dict) for req in framework.requirements: checks = req.checks diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py index 0d2cd5313b..a55789a7fd 100644 --- a/tests/lib/outputs/compliance/display_compliance_table_test.py +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -94,21 +94,6 @@ class TestDispatchStartswith: display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() - @pytest.mark.parametrize( - "framework_name", - [ - "csa_ccm_4.0_aws", - "csa_ccm_4.0_azure", - "csa_ccm_4.0_gcp", - "csa_ccm_4.0_oraclecloud", - "csa_ccm_4.0_alibabacloud", - ], - ) - @patch(f"{MODULE}.get_csa_table") - def test_csa_dispatch(self, mock_fn, framework_name): - display_compliance_table(compliance_framework=framework_name, **_COMMON) - mock_fn.assert_called_once() - @pytest.mark.parametrize( "framework_name", ["c5_aws", "c5_azure", "c5_gcp"], diff --git a/tests/lib/outputs/compliance/process_universal_test.py b/tests/lib/outputs/compliance/process_universal_test.py index fa8b737ddd..4dc957ed4f 100644 --- a/tests/lib/outputs/compliance/process_universal_test.py +++ b/tests/lib/outputs/compliance/process_universal_test.py @@ -12,6 +12,7 @@ Also validates that print_compliance_frameworks and print_compliance_requirement work with universal ComplianceFramework objects (dict checks, None provider). """ +import csv import json import os from datetime import datetime, timezone @@ -124,6 +125,41 @@ def _make_universal_framework(name="TestFW", version="1.0", with_table_config=Tr ) +def _make_framework_with_manual(name="MixedFW", version="1.0"): + """Framework with one aws-covered requirement and one manual one. + + The manual requirement has no aws checks, so for provider ``aws`` it is + emitted as a manual row/event — used to assert manual requirements are + not duplicated when the writer is reused across streaming batches. + """ + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="Covered requirement", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="Manual requirement", + attributes={"Section": "GOV"}, + checks={"aws": []}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + outputs = OutputsConfig(table_config=TableConfig(group_by="Section")) + return ComplianceFramework( + framework=name, + name=f"{name} Framework", + provider="AWS", + version=version, + description="Test framework", + requirements=reqs, + attributes_metadata=metadata, + outputs=outputs, + ) + + # ── Tests ──────────────────────────────────────────────────────────── @@ -728,3 +764,243 @@ class TestIdempotency: # FW1 writer instances unchanged assert second_writers[0] is first_writers[0] assert second_writers[1] is first_writers[1] + + +class TestStreamingBatches: + """Streaming-aware behaviour: ``from_cli`` / ``is_last`` / ``_flush``. + + Regression coverage for the API streaming path where the helper is + invoked once per finding batch: before the fix only the first batch + was written (batches 2..N silently dropped) and manual requirements + were re-emitted on every batch. + """ + + def _run_batches(self, tmp_path, fw, key, batches): + """Invoke the helper once per (findings, is_last) batch, sharing + ``generated_outputs`` so writers are reused like the API does.""" + generated = {"compliance": []} + for findings, is_last in batches: + process_universal_compliance_frameworks( + input_compliance_frameworks={key}, + universal_frameworks={key: fw}, + finding_outputs=findings, + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=is_last, + ) + return generated + + def test_defaults_preserve_cli_single_call(self, tmp_path): + """Defaults (``from_cli=True``, ``is_last=True``): a single call + still finalizes a valid, closed OCSF JSON array (CLI unchanged).""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + ocsf_path = tmp_path / "compliance" / "out_test_fw_1.0.ocsf.json" + data = json.loads(ocsf_path.read_text()) + assert isinstance(data, list) and len(data) >= 1 + + def test_multibatch_csv_keeps_every_batch(self, tmp_path): + """Findings from batches 2..N must not be dropped (the bug).""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + generated = self._run_batches( + tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)] + ) + content = (tmp_path / "compliance" / "out_fw_1.0.csv").read_text() + assert "check_a is PASS" in content # batch 1 + assert "check_a is FAIL" in content # batch 2 — regression + # writer reused, not recreated: still just 1 CSV + 1 OCSF + assert len(generated["compliance"]) == 2 + + def test_multibatch_ocsf_valid_array_with_every_batch(self, tmp_path): + """OCSF is a valid (closed) JSON array holding every batch's + events only after the ``is_last=True`` call.""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + data = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + assert isinstance(data, list) + assert len(data) >= 2 # one event per batch finding + + def test_manual_requirement_not_duplicated_across_batches(self, tmp_path): + """Manual requirement is emitted once (first batch, via __init__), + never re-emitted when the writer is reused (``include_manual=False``).""" + fw = _make_framework_with_manual() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + rows = list( + csv.DictReader( + (tmp_path / "compliance" / "out_fw_1.0.csv").read_text().splitlines(), + delimiter=";", + ) + ) + manual_rows = [r for r in rows if r["STATUS"] == "MANUAL"] + assert len(manual_rows) == 1 + assert manual_rows[0]["REQUIREMENTS_ID"] == "2.1" + + ocsf = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + manual_events = [ + e + for e in ocsf + if (e.get("compliance") or {}).get("requirements") == ["2.1"] + ] + assert len(manual_events) == 1 + + def test_writer_reused_not_recreated_across_batches(self, tmp_path): + """Three batches still yield exactly one CSV + one OCSF writer, + and the same instances are reused throughout.""" + fw = _make_universal_framework() + generated = self._run_batches( + tmp_path, + fw, + "fw_1.0", + [ + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], True), + ], + ) + assert len(generated["compliance"]) == 2 + assert isinstance(generated["compliance"][0], UniversalComplianceOutput) + assert isinstance(generated["compliance"][1], OCSFComplianceOutput) + + def test_label_without_version_still_outputs(self, tmp_path): + """Empty framework version → label is the framework name only; + the helper still produces both artifacts without error.""" + fw = _make_universal_framework(version="") + generated = {"compliance": []} + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"fw"}, + universal_frameworks={"fw": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=True, + ) + assert processed == {"fw"} + assert len(generated["compliance"]) == 2 + assert (tmp_path / "compliance" / "out_fw.csv").exists() + assert (tmp_path / "compliance" / "out_fw.ocsf.json").exists() + + +def _csa_like_framework() -> ComplianceFramework: + """Build a CSA CCM-style universal framework with checks across providers.""" + requirement = UniversalComplianceRequirement( + id="A&A-01", + description="Audit and Assurance", + attributes={"Section": "Audit"}, + checks={ + "aws": ["aws_check"], + "azure": ["azure_check"], + "gcp": ["gcp_check"], + }, + ) + return ComplianceFramework( + framework="CSA_CCM", + name="CSA Cloud Controls Matrix", + version="4.0", + description="Multi-provider framework", + requirements=[requirement], + attributes_metadata=[AttributeMetadata(key="Section", type="str")], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +class TestMultiProviderUniversalFramework: + """A top-level CSA-CCM-style framework produces a CSV+OCSF pair scoped + to the provider it is invoked with.""" + + @pytest.mark.parametrize( + "provider,check_id", + [ + ("aws", "aws_check"), + ("azure", "azure_check"), + ("gcp", "gcp_check"), + ], + ) + def test_per_provider_outputs_isolated(self, tmp_path, provider, check_id): + framework = _csa_like_framework() + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"csa_ccm_4.0"}, + universal_frameworks={"csa_ccm_4.0": framework}, + finding_outputs=[_make_finding(check_id, provider=provider)], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider=provider, + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "prowler_output_csa_ccm_4.0.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) + non_manual = [event for event in events if event.get("status_code") != "MANUAL"] + assert len(non_manual) == 1 + assert non_manual[0]["compliance"]["checks"][0]["uid"] == check_id + + +class TestMitreStyleOCSFOutput: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key through the OCSF pipeline.""" + + def test_mitre_raw_attributes_pass_through_pipeline(self, tmp_path): + mitre_requirement = UniversalComplianceRequirement( + id="T1078", + description="Valid Accounts", + attributes={ + "_raw_attributes": [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + framework = ComplianceFramework( + framework="MITRE", + name="MITRE ATT&CK", + version="14", + description="Mitre", + requirements=[mitre_requirement], + outputs=OutputsConfig(table_config=TableConfig(group_by="AWSService")), + ) + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"mitre_attack_aws"}, + universal_frameworks={"mitre_attack_aws": framework}, + finding_outputs=[_make_finding("check_a", "PASS")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "out_mitre_attack_aws.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) and len(events) >= 1 + for event in events: + requirement_attrs = (event.get("unmapped") or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py index 71429fc1ca..db6b78de28 100644 --- a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py @@ -202,6 +202,26 @@ class TestOCSFComplianceOutput: assert cf.status_code == "MANUAL" assert cf.finding_info.uid == "manual-MANUAL-1" + def test_include_manual_false_skips_manual(self): + """``_transform(..., include_manual=False)`` emits check events but + NOT manual requirement events. The streaming caller passes ``False`` + for batches 2..N so manual events are not duplicated.""" + covered = _simple_requirement("REQ-1", ["check_a"]) + manual = _simple_requirement("MANUAL-1", checks=[]) + fw = _make_framework([covered, manual]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + # __init__ transforms with include_manual=True (default) → manual present + assert any(cf.status_code == "MANUAL" for cf in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the check event, no manual + assert all(cf.status_code != "MANUAL" for cf in output.data) + def test_multi_provider_checks_dict(self): req = UniversalComplianceRequirement( id="REQ-1", @@ -631,3 +651,103 @@ class TestNoTopLevelOCSFImport: import prowler.lib.outputs.compliance.universal.ocsf_compliance as mod assert "OCSF" not in dir(mod) + + +def _mitre_requirement(req_id="T1078", entries=None): + """Build a MITRE-style requirement with `_raw_attributes` wrapping.""" + return UniversalComplianceRequirement( + id=req_id, + description="Valid Accounts", + attributes={ + "_raw_attributes": entries + or [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + + +class TestMitreRawAttributes: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key into the OCSF payload.""" + + def test_raw_attributes_key_not_in_unmapped(self): + framework = _make_framework([_mitre_requirement()]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + requirement_attrs = (output.data[0].unmapped or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs + + def test_finding_serializes_with_raw_attributes(self): + framework = _make_framework( + [ + _mitre_requirement( + entries=[ + {"AWSService": "IAM", "Category": "Initial Access"}, + {"AWSService": "STS", "Category": "Privilege Escalation"}, + ] + ) + ] + ) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + compliance_finding = output.data[0] + if hasattr(compliance_finding, "model_dump_json"): + payload = json.loads(compliance_finding.model_dump_json(exclude_none=True)) + else: + payload = json.loads(compliance_finding.json(exclude_none=True)) + assert payload["compliance"]["requirements"] == ["T1078"] + + +class TestProviderFiltering: + """OCSF writer scopes findings against `requirement.checks[provider]`.""" + + def test_check_for_other_provider_not_emitted(self): + azure_only_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Azure-only requirement", + attributes={}, + checks={"azure": ["check_a"]}, + ) + framework = _make_framework([azure_only_requirement]) + findings = [_make_finding("check_a", "PASS", provider="aws")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + assert all( + compliance_finding.status_code == "MANUAL" + for compliance_finding in output.data + ) + + def test_no_provider_aggregates_all_checks(self): + multi_provider_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Multi-provider requirement", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + framework = _make_framework([multi_provider_requirement]) + findings = [ + _make_finding("check_a", "PASS", provider="aws"), + _make_finding("check_b", "FAIL", provider="azure"), + ] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider=None + ) + + statuses = sorted( + compliance_finding.status_code for compliance_finding in output.data + ) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index 391a9015d7..d0fdf0f178 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -122,6 +122,43 @@ class TestManualRequirements: assert manual_rows[0].dict()["Requirements_Id"] == "manual-1" assert manual_rows[0].dict()["ResourceId"] == "manual_check" + def test_include_manual_false_skips_manual_rows(self, tmp_path): + """``_transform(..., include_manual=False)`` emits finding rows but + NOT manual requirements. The streaming caller passes ``False`` for + batches 2..N so manual rows are not duplicated across batches.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="manual-1", + description="manual check", + attributes={"Section": "Governance"}, + checks={}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + findings = [_make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]})] + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "t.csv"), + ) + # __init__ transforms with include_manual=True (default) → manual present + assert any(r.dict()["Status"] == "MANUAL" for r in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the finding row, no manual + assert all(r.dict()["Status"] != "MANUAL" for r in output.data) + class TestMITREExtraColumns: def test_mitre_columns_present(self, tmp_path): diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index da6effa38b..8ed4a4418c 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.30.0] (Prowler UNRELEASED) + +### 🚀 Added + +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) + +--- + ## [1.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index a121da3e50..318004ea96 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -394,6 +394,27 @@ export const getComplianceCsv = async (scanId: string, complianceId: string) => "compliance report", ); +/** + * Get the OCSF JSON export for a universal compliance framework. + * + * Only universal frameworks that declare an ``outputs`` block (today: DORA, + * CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework + * the backend returns 404; callers should gate this download via + * ``isOcsfSupported(framework)``. + * + * NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query + * param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any + * non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an + * option — the format must be encoded in the route. + */ +export const getComplianceOcsf = async (scanId: string, complianceId: string) => + _fetchScanBinary( + scanId, + `compliance/${complianceId}/ocsf`, + `scan-${scanId}-compliance-${complianceId}.ocsf.json`, + "compliance OCSF report", + ); + /** * Get a compliance PDF report for any supported framework. * diff --git a/ui/components/compliance/compliance-custom-details/dora-details.tsx b/ui/components/compliance/compliance-custom-details/dora-details.tsx new file mode 100644 index 0000000000..3d84e891ba --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/dora-details.tsx @@ -0,0 +1,49 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface DORADetailsProps { + requirement: Requirement; +} + +export const DORACustomDetails = ({ requirement }: DORADetailsProps) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.pillar && ( + + )} + {requirement.article && ( + + )} + {requirement.article_title && ( + + )} + + + ); +}; diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 81774b686f..70ff11c1c2 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -6,15 +6,19 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted( - () => ({ - downloadComplianceCsvMock: vi.fn(), - downloadCompliancePdfMock: vi.fn(), - }), -); +const { + downloadComplianceCsvMock, + downloadComplianceOcsfMock, + downloadCompliancePdfMock, +} = vi.hoisted(() => ({ + downloadComplianceCsvMock: vi.fn(), + downloadComplianceOcsfMock: vi.fn(), + downloadCompliancePdfMock: vi.fn(), +})); vi.mock("@/lib/helper", () => ({ downloadComplianceCsv: downloadComplianceCsvMock, + downloadComplianceOcsf: downloadComplianceOcsfMock, downloadCompliancePdf: downloadCompliancePdfMock, })); @@ -131,4 +135,51 @@ describe("ComplianceDownloadContainer", () => { {}, ); }); + + it("should hide the OCSF action for frameworks without OCSF support", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + + expect(screen.queryByText("Download OCSF report")).not.toBeInTheDocument(); + }); + + it("should surface and trigger the OCSF download for universal frameworks", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + expect(screen.getByText("Download OCSF report")).toBeInTheDocument(); + + await user.click( + screen.getByRole("menuitem", { name: /Download OCSF report/i }), + ); + + expect(downloadComplianceOcsfMock).toHaveBeenCalledWith( + "scan-1", + "dora", + {}, + ); + }); }); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 6d2e0ddfee..6e29acc213 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -1,6 +1,6 @@ "use client"; -import { DownloadIcon, FileTextIcon } from "lucide-react"; +import { DownloadIcon, FileJsonIcon, FileTextIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; @@ -14,8 +14,15 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; -import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types"; -import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper"; +import { + type ComplianceReportType, + isOcsfSupported, +} from "@/lib/compliance/compliance-report-types"; +import { + downloadComplianceCsv, + downloadComplianceOcsf, + downloadCompliancePdf, +} from "@/lib/helper"; import { cn } from "@/lib/utils"; interface ComplianceDownloadContainerProps { @@ -40,9 +47,14 @@ export const ComplianceDownloadContainer = ({ presentation = "buttons", }: ComplianceDownloadContainerProps) => { const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); const isIconWidth = buttonWidth === "icon"; const isDropdown = presentation === "dropdown"; + // Only universal frameworks declaring an ``outputs`` block expose a + // per-framework OCSF artifact (today: DORA, CSA CCM 4.0). Hide the + // action everywhere else so the user never hits a guaranteed 404. + const ocsfAvailable = isOcsfSupported(complianceId); const handleDownloadCsv = async () => { if (isDownloadingCsv) return; @@ -54,6 +66,16 @@ export const ComplianceDownloadContainer = ({ } }; + const handleDownloadOcsf = async () => { + if (!ocsfAvailable || isDownloadingOcsf) return; + setIsDownloadingOcsf(true); + try { + await downloadComplianceOcsf(scanId, complianceId, toast); + } finally { + setIsDownloadingOcsf(false); + } + }; + const handleDownloadPdf = async () => { if (!reportType || isDownloadingPdf) return; setIsDownloadingPdf(true); @@ -105,6 +127,18 @@ export const ComplianceDownloadContainer = ({ onSelect={handleDownloadCsv} disabled={disabled || isDownloadingCsv} /> + {ocsfAvailable && ( + + } + label="Download OCSF report" + onSelect={handleDownloadOcsf} + disabled={disabled || isDownloadingOcsf} + /> + )} {reportType && ( Download CSV report )} + {ocsfAvailable && ( + + + + + {showTooltip && ( + Download OCSF report + )} + + )} {reportType && ( diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index d9e7d0ce1c..fe493e2096 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -6,6 +6,7 @@ import CCCLogo from "./ccc.svg"; import CISLogo from "./cis.svg"; import CISALogo from "./cisa.svg"; import CSALogo from "./csa.svg"; +import DORALogo from "./dora.svg"; import ENSLogo from "./ens.png"; import FedRAMPLogo from "./fedramp.svg"; import FFIECLogo from "./ffiec.svg"; @@ -67,6 +68,9 @@ const COMPLIANCE_LOGOS = [ ["c5", C5Logo], ["ccc", CCCLogo], ["csa", CSALogo], + // DORA — universal framework (`prowler/compliance/dora.json`). The + // compliance_id is just `dora`, no provider suffix. + ["dora", DORALogo], ["secnumcloud", ANSSILogo], ["aws", AWSLogo], ] as const; diff --git a/ui/components/icons/compliance/dora.svg b/ui/components/icons/compliance/dora.svg new file mode 100644 index 0000000000..8ba02e218f --- /dev/null +++ b/ui/components/icons/compliance/dora.svg @@ -0,0 +1,13 @@ + + + + + + + + + + DORA + EU 2022/2554 + + diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index c42c548695..ba8c97ecfc 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -6,6 +6,7 @@ import { C5CustomDetails } from "@/components/compliance/compliance-custom-detai import { CCCCustomDetails } from "@/components/compliance/compliance-custom-details/ccc-details"; import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details"; import { CSACustomDetails } from "@/components/compliance/compliance-custom-details/csa-details"; +import { DORACustomDetails } from "@/components/compliance/compliance-custom-details/dora-details"; import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details"; import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details"; import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; @@ -47,6 +48,10 @@ import { mapComplianceData as mapCSAComplianceData, toAccordionItems as toCSAAccordionItems, } from "./csa"; +import { + mapComplianceData as mapDORAComplianceData, + toAccordionItems as toDORAAccordionItems, +} from "./dora"; import { mapComplianceData as mapENSComplianceData, toAccordionItems as toENSAccordionItems, @@ -208,6 +213,19 @@ const getComplianceMappers = (): Record => ({ getDetailsComponent: (requirement: Requirement) => createElement(CSACustomDetails, { requirement }), }, + // DORA (Regulation (EU) 2022/2554) — universal framework keyed by the + // `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by + // Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in + // the requirement detail drawer. + DORA: { + mapComplianceData: mapDORAComplianceData, + toAccordionItems: toDORAAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(DORACustomDetails, { requirement }), + }, }); /** diff --git a/ui/lib/compliance/compliance-report-types.test.ts b/ui/lib/compliance/compliance-report-types.test.ts index 2110df41d2..82c6cd49f9 100644 --- a/ui/lib/compliance/compliance-report-types.test.ts +++ b/ui/lib/compliance/compliance-report-types.test.ts @@ -4,6 +4,7 @@ import { COMPLIANCE_REPORT_TYPES, getReportTypeForCompliance, getReportTypeForFramework, + isOcsfSupported, pickLatestCisPerProvider, } from "./compliance-report-types"; @@ -34,6 +35,24 @@ describe("getReportTypeForFramework", () => { }); }); +describe("isOcsfSupported", () => { + it("returns true for universal frameworks shipping an OCSF artifact", () => { + expect(isOcsfSupported("dora")).toBe(true); + expect(isOcsfSupported("csa_ccm_4.0")).toBe(true); + }); + + it("returns false for legacy/per-provider frameworks without OCSF output", () => { + expect(isOcsfSupported("cis_5.0_aws")).toBe(false); + expect(isOcsfSupported("ens_rd2022_aws")).toBe(false); + expect(isOcsfSupported("nis2_aws")).toBe(false); + }); + + it("returns false for missing or empty inputs", () => { + expect(isOcsfSupported(undefined)).toBe(false); + expect(isOcsfSupported("")).toBe(false); + }); +}); + describe("pickLatestCisPerProvider", () => { it("returns an empty set for an empty input", () => { const latest = pickLatestCisPerProvider([]); @@ -95,7 +114,7 @@ describe("pickLatestCisPerProvider", () => { const latest = pickLatestCisPerProvider([ "ens_rd2022_aws", "nis2_aws", - "csa_ccm_4.0_aws", + "csa_ccm_4.0", "prowler_threatscore_aws", "cis_5.0_aws", ]); diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts index 947dbfd8bc..a9a160e09c 100644 --- a/ui/lib/compliance/compliance-report-types.ts +++ b/ui/lib/compliance/compliance-report-types.ts @@ -161,6 +161,30 @@ export const pickLatestCisPerProvider = ( return latest; }; +/** + * Compliance IDs that ship a per-framework OCSF JSON export. + * + * Only universal compliance frameworks that declare an ``outputs`` block in + * their schema (see ``prowler/compliance/.json``) produce a dedicated + * OCSF artifact during scan output generation. Today that is DORA and + * CSA CCM 4.0. Any other framework only offers CSV (and, for the curated + * list above, PDF). + * + * Keep this Set in lock-step with the backend: ``get_prowler_provider_compliance`` + * + ``ComplianceFramework.outputs`` is the source of truth. The API will + * 404 on ``GET /scans/{id}/compliance/{name}/ocsf`` for any framework not + * in this set, so showing the OCSF button for an unsupported framework + * would surface a broken download — gate every call site through + * ``isOcsfSupported``. + */ +const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet = new Set([ + "dora", + "csa_ccm_4.0", +]); + +export const isOcsfSupported = (complianceId: string | undefined): boolean => + !!complianceId && OCSF_SUPPORTED_COMPLIANCE_IDS.has(complianceId); + /** * Resolve the report type for a compliance card. * diff --git a/ui/lib/compliance/dora.tsx b/ui/lib/compliance/dora.tsx new file mode 100644 index 0000000000..052bb9deb3 --- /dev/null +++ b/ui/lib/compliance/dora.tsx @@ -0,0 +1,154 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + DORAAttributesMetadata, + Framework, + Requirement, + REQUIREMENT_STATUS, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +import { + calculateFrameworkCounters, + createRequirementsMap, + findOrCreateCategory, + findOrCreateControl, + findOrCreateFramework, +} from "./commons"; + +// Display order for DORA pillars in the accordion and any grouped chart. The +// regulation arranges them in this exact order (Articles 5-14, 17-19, 24-25, +// 28+30, 45) — preserving it here means the UI always renders pillars in the +// "logical" reading order regardless of how the API returns them. +export const DORA_PILLAR_ORDER: readonly string[] = [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing", +]; + +const getStatusCounters = (status: RequirementStatus) => ({ + pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, + fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, + manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, +}); + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; + + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as DORAAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + // Group by Pillar (top-level accordion section). Article + ArticleTitle + // live inside the requirement so they show up on the detail drawer. + const categoryName = attrs.Pillar; + const requirementName = attributeItem.attributes.name || ""; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + + const framework = findOrCreateFramework(frameworks, frameworkName); + const category = findOrCreateCategory(framework.categories, categoryName); + // Flat 2-level structure: pillar → requirements (no intermediate control). + const control = findOrCreateControl(category.controls, categoryName); + + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName ? `${id} - ${requirementName}` : id, + description, + status: finalStatus, + check_ids: checks, + ...getStatusCounters(finalStatus), + pillar: attrs.Pillar, + article: attrs.Article, + article_title: attrs.ArticleTitle, + }; + + control.requirements.push(requirement); + } + + // Sort categories by canonical pillar order so DORA always reads from "ICT + // Risk Management" down to "Information Sharing", regardless of map insertion + // order driven by the API response. + for (const framework of frameworks) { + framework.categories.sort((a, b) => { + const ia = DORA_PILLAR_ORDER.indexOf(a.name); + const ib = DORA_PILLAR_ORDER.indexOf(b.name); + // Unknown pillars (defensive — shouldn't happen) sink to the bottom. + const orderA = ia === -1 ? DORA_PILLAR_ORDER.length : ia; + const orderB = ib === -1 ? DORA_PILLAR_ORDER.length : ib; + return orderA - orderB; + }); + } + + calculateFrameworkCounters(frameworks); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + const safeId = scanId || ""; + + return data.flatMap((framework) => + framework.categories.map((category) => ({ + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + // Pillar → requirements (flat, no intermediate "control" level). + items: category.controls.flatMap((control) => + control.requirements.map((requirement, reqIndex) => ({ + key: `${framework.name}-${category.name}-req-${reqIndex}`, + title: ( + + ), + content: ( + + ), + items: [], + })), + ), + })), + ); +}; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index d460b9697c..2618b23a33 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -1,5 +1,6 @@ import { getComplianceCsv, + getComplianceOcsf, getCompliancePdfReport, type ScanBinaryResult, } from "@/actions/scans"; @@ -247,6 +248,32 @@ export const downloadComplianceCsv = async ( ); }; +/** + * Download the per-framework OCSF JSON export. + * + * Only universal frameworks declaring an ``outputs`` block produce this + * artifact (currently DORA and CSA CCM 4.0); callers must gate the call + * via ``isOcsfSupported`` to avoid surfacing a broken download on + * frameworks the API will 404 on. + */ +export const downloadComplianceOcsf = async ( + scanId: string, + complianceId: string, + toast: ReturnType["toast"], +): Promise => { + toast({ + title: "Download Started", + description: "Preparing the OCSF report. This may take a moment.", + }); + const result = await getComplianceOcsf(scanId, complianceId); + await downloadFile( + result, + "application/json", + "The compliance OCSF report has been downloaded successfully.", + toast, + ); +}; + /** * Download a compliance PDF report. * diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 9ea851c2d4..e926efe356 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -327,6 +327,31 @@ export interface ASDEssentialEightRequirement extends Requirement { references: ASDEssentialEightAttributesMetadata["References"]; } +// DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554). +// Universal framework — flat attributes dict with Pillar/Article/ArticleTitle. +// `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors +// the five DORA pillars declared in `prowler/compliance/dora.json`. +export const DORA_PILLAR = { + ICT_RISK_MANAGEMENT: "ICT Risk Management", + INCIDENT_REPORTING: "ICT-Related Incident Reporting", + RESILIENCE_TESTING: "Digital Operational Resilience Testing", + THIRD_PARTY_RISK: "ICT Third-Party Risk Management", + INFORMATION_SHARING: "Information Sharing", +} as const; +export type DORAPillar = (typeof DORA_PILLAR)[keyof typeof DORA_PILLAR]; + +export interface DORAAttributesMetadata { + Pillar: DORAPillar; + Article: string; + ArticleTitle: string; +} + +export interface DORARequirement extends Requirement { + pillar: DORAAttributesMetadata["Pillar"]; + article: DORAAttributesMetadata["Article"]; + article_title: DORAAttributesMetadata["ArticleTitle"]; +} + export interface AttributesItemData { type: "compliance-requirements-attributes"; id: string; @@ -349,6 +374,7 @@ export interface AttributesItemData { | CCCAttributesMetadata[] | CSAAttributesMetadata[] | ASDEssentialEightAttributesMetadata[] + | DORAAttributesMetadata[] | GenericAttributesMetadata[]; check_ids: string[]; // MITRE structure From e60a4462e5417cea26ad3eebdd0a7f4030b3f5d7 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:08:06 +0200 Subject: [PATCH 007/129] fix(ui): refine add-provider wizard flow between scans and providers (#11424) --- ui/CHANGELOG.md | 16 ++ .../_components/accounts-selector.test.tsx | 45 ++- .../_components/accounts-selector.tsx | 76 +++-- .../provider-type-selector.test.tsx | 26 +- .../_components/provider-type-selector.tsx | 212 +++----------- .../table/provider-icon-cell.test.tsx | 45 +++ .../findings/table/provider-icon-cell.tsx | 47 +--- .../provider-type-icon.test.tsx | 126 +++++++++ .../providers-badge/provider-type-icon.tsx | 250 +++++++++++++++++ .../integrations/s3/s3-integration-form.tsx | 12 +- .../security-hub-integration-form.tsx | 12 +- .../providers/no-providers-added.tsx | 92 +++++++ .../providers-accounts-view.test.tsx | 260 +++++++++++++++++- .../providers/providers-accounts-view.tsx | 83 ++++-- .../hooks/use-provider-wizard-controller.ts | 9 +- .../wizard/provider-wizard-modal.tsx | 3 + ui/components/scans/no-providers-added.tsx | 38 --- .../scans-providers-empty-state.test.tsx | 74 ++--- .../scans/scans-providers-empty-state.tsx | 34 +-- ui/dependency-log.json | 24 +- ui/lib/providers-navigation.ts | 3 + ui/package.json | 8 +- ui/pnpm-lock.yaml | 213 +++++++------- ui/tests/helpers.ts | 31 ++- ui/tests/providers/providers-page.ts | 9 +- 25 files changed, 1198 insertions(+), 550 deletions(-) create mode 100644 ui/components/findings/table/provider-icon-cell.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.tsx create mode 100644 ui/components/providers/no-providers-added.tsx delete mode 100644 ui/components/scans/no-providers-added.tsx create mode 100644 ui/lib/providers-navigation.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8ed4a4418c..db38de5fd3 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,22 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.29.2] (Prowler v5.29.2) + +### 🔄 Changed + +- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🐞 Fixed + +- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🔐 Security + +- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +--- + ## [1.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 626e0985da..19c63cf3d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ); }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -220,4 +220,45 @@ describe("AccountsSelector", () => { expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); }); + + it("shows the provider icon next to the name in the trigger for a single selection", async () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect(within(trigger).getByText("Production AWS")).toBeInTheDocument(); + }); + + it("renders one icon per selected account without deduping by provider type", async () => { + const secondAws = { + ...providers[0], + id: "provider-2", + attributes: { + ...providers[0].attributes, + uid: "999999999999", + alias: "Staging AWS", + }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + // Two AWS accounts -> two AWS icons in the trigger (no dedupe). + expect(await within(trigger).findAllByText("AWS")).toHaveLength(2); + expect( + within(trigger).getByText("2 Providers selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index a37807e793..0c389febb7 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,26 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { Badge } from "@/components/shadcn"; import { MultiSelect, @@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = { type AccountSelectorFilter = (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , - vercel: , - okta: , -}; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; @@ -158,10 +125,36 @@ export function AccountsSelector({ if (selectedIds.length === 1) { const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; - return {name}; + return ( + + {p && ( + + )} + {name} + + ); } + // One icon per selected account (no dedupe): two accounts of the same + // provider show two icons, disambiguated by the UID tooltip on hover. + const items = selectedIds + .map((selectedId) => + providers.find((pr) => getProviderValue(pr) === selectedId), + ) + .filter((p): p is ProviderProps => Boolean(p)) + .map((p) => ({ + key: p.id, + type: p.attributes.provider as ProviderType, + tooltip: p.attributes.uid, + })); return ( - {selectedIds.length} Providers selected + + + + {selectedIds.length} Providers selected + + ); }; @@ -208,7 +201,6 @@ export function AccountsSelector({ const isDisabled = disabledValuesSet.has(value); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; - const icon = PROVIDER_ICON[providerType]; const searchKeywords = [ displayName, p.attributes.alias, @@ -228,7 +220,9 @@ export function AccountsSelector({ if (closeOnSelect) setSelectorOpen(false); }} > - + {displayName} {isDisabled && Disconnected} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index 5cd94a3087..b2c05b336d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProviderTypeSelector } from "./provider-type-selector"; @@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
{children}
), MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => { ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("shows one icon per selected type and a count in the trigger", async () => { + const azure = { + ...providers[0], + id: "provider-2", + attributes: { ...providers[0].attributes, provider: "azure" as const }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect( + within(trigger).getByText("2 Provider Types selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 15d069b49d..e78412eeeb 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,8 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { type ComponentType, lazy, Suspense } from "react"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { MultiSelect, MultiSelectContent, @@ -14,163 +18,6 @@ import { import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; -const AWSProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AWSProviderBadge, - })), -); -const AzureProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AzureProviderBadge, - })), -); -const GCPProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GCPProviderBadge, - })), -); -const KS8ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.KS8ProviderBadge, - })), -); -const M365ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.M365ProviderBadge, - })), -); -const GitHubProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GitHubProviderBadge, - })), -); -const IacProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.IacProviderBadge, - })), -); -const ImageProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.ImageProviderBadge, - })), -); -const OracleCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OracleCloudProviderBadge, - })), -); -const MongoDBAtlasProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.MongoDBAtlasProviderBadge, - })), -); -const AlibabaCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AlibabaCloudProviderBadge, - })), -); -const CloudflareProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.CloudflareProviderBadge, - })), -); -const OpenStackProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OpenStackProviderBadge, - })), -); -const GoogleWorkspaceProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GoogleWorkspaceProviderBadge, - })), -); -const VercelProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.VercelProviderBadge, - })), -); -const OktaProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OktaProviderBadge, - })), -); - -type IconProps = { width: number; height: number }; - -const IconPlaceholder = ({ width, height }: IconProps) => ( -
-); - -const PROVIDER_DATA: Record< - ProviderType, - { label: string; icon: ComponentType } -> = { - aws: { - label: "Amazon Web Services", - icon: AWSProviderBadge, - }, - azure: { - label: "Microsoft Azure", - icon: AzureProviderBadge, - }, - gcp: { - label: "Google Cloud Platform", - icon: GCPProviderBadge, - }, - kubernetes: { - label: "Kubernetes", - icon: KS8ProviderBadge, - }, - m365: { - label: "Microsoft 365", - icon: M365ProviderBadge, - }, - github: { - label: "GitHub", - icon: GitHubProviderBadge, - }, - googleworkspace: { - label: "Google Workspace", - icon: GoogleWorkspaceProviderBadge, - }, - iac: { - label: "Infrastructure as Code", - icon: IacProviderBadge, - }, - image: { - label: "Container Registry", - icon: ImageProviderBadge, - }, - oraclecloud: { - label: "Oracle Cloud Infrastructure", - icon: OracleCloudProviderBadge, - }, - mongodbatlas: { - label: "MongoDB Atlas", - icon: MongoDBAtlasProviderBadge, - }, - alibabacloud: { - label: "Alibaba Cloud", - icon: AlibabaCloudProviderBadge, - }, - cloudflare: { - label: "Cloudflare", - icon: CloudflareProviderBadge, - }, - openstack: { - label: "OpenStack", - icon: OpenStackProviderBadge, - }, - vercel: { - label: "Vercel", - icon: VercelProviderBadge, - }, - okta: { - label: "Okta", - icon: OktaProviderBadge, - }, -}; - /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; @@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({ .map((p) => p.attributes.provider), ), ) - .filter((type): type is ProviderType => type in PROVIDER_DATA) + .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) .sort((a, b) => - PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), ); - const renderIcon = (providerType: ProviderType) => { - const IconComponent = PROVIDER_DATA[providerType].icon; - return ( - }> - - - ); - }; - const selectedLabel = () => { if (selectedTypes.length === 0) return null; if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + + + {PROVIDER_TYPE_DATA[providerType].label} + ); } return ( - - {selectedTypes.length} Provider Types selected + + ({ + key: type, + type, + tooltip: PROVIDER_TYPE_DATA[type].label, + }))} + /> + + {selectedTypes.length} Provider Types selected + ); }; @@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_DATA[providerType].label} + + {PROVIDER_TYPE_DATA[providerType].label} ))} diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx new file mode 100644 index 0000000000..593122d33a --- /dev/null +++ b/ui/components/findings/table/provider-icon-cell.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderIconCell } from "./provider-icon-cell"; + +// Render the lazy provider badges as plain text so we can assert on them. The +// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +describe("ProviderIconCell", () => { + it("renders the shared provider-type icon for a known provider", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a '?' placeholder for a provider type missing from the map", () => { + render( + , + ); + + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 05a35b3dc2..350e730e78 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -1,43 +1,10 @@ import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { cn } from "@/lib/utils"; import { ProviderType } from "@/types"; -export const PROVIDER_ICONS = { - aws: AWSProviderBadge, - azure: AzureProviderBadge, - gcp: GCPProviderBadge, - kubernetes: KS8ProviderBadge, - m365: M365ProviderBadge, - github: GitHubProviderBadge, - googleworkspace: GoogleWorkspaceProviderBadge, - iac: IacProviderBadge, - image: ImageProviderBadge, - oraclecloud: OracleCloudProviderBadge, - mongodbatlas: MongoDBAtlasProviderBadge, - alibabacloud: AlibabaCloudProviderBadge, - cloudflare: CloudflareProviderBadge, - openstack: OpenStackProviderBadge, - vercel: VercelProviderBadge, - okta: OktaProviderBadge, -} as const; - interface ProviderIconCellProps { provider: ProviderType; size?: number; @@ -49,9 +16,9 @@ export const ProviderIconCell = ({ size = 26, className = "size-8 rounded-md bg-white", }: ProviderIconCellProps) => { - const IconComponent = PROVIDER_ICONS[provider]; - - if (!IconComponent) { + // Unknown provider types (present in the data but missing from the shared + // PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon. + if (!(provider in PROVIDER_TYPE_DATA)) { return (
? @@ -66,7 +33,7 @@ export const ProviderIconCell = ({ className, )} > - +
); }; diff --git a/ui/components/icons/providers-badge/provider-type-icon.test.tsx b/ui/components/icons/providers-badge/provider-type-icon.test.tsx new file mode 100644 index 0000000000..96b4f5f4d4 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon"; + +// A provider type the API may return but this UI build does not know about. +const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType; + +// Render the lazy provider badges as plain text so we can assert on them. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +// Render the tooltip pieces inline so the hover content is queryable in jsdom. +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +describe("ProviderTypeIcon", () => { + it("renders the badge for the given provider type", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a sized placeholder instead of crashing for an unknown type", () => { + // Regression guard for #9991: an unknown provider type must not throw. + const { container } = render( + , + ); + + expect(screen.queryByText("AWS")).not.toBeInTheDocument(); + expect(container.querySelector("div")).toHaveStyle({ + width: "24px", + height: "24px", + }); + }); +}); + +describe("ProviderTypeIconStack", () => { + it("renders one icon per item without deduping by type", async () => { + render( + , + ); + + // Two AWS accounts -> two AWS icons (no dedupe). + expect(await screen.findAllByText("AWS")).toHaveLength(2); + }); + + it("shows each item's tooltip text on the icon", async () => { + render( + , + ); + + expect(await screen.findByTestId("tooltip")).toHaveTextContent( + "account-uid-123", + ); + }); + + it("collapses items beyond `max` into a +N badge", async () => { + render( + , + ); + + expect(await screen.findByTestId("badge")).toHaveTextContent("+2"); + // First icon is shown; items sliced beyond `max` never reach the DOM. + expect(await screen.findByText("AWS")).toBeInTheDocument(); + expect(screen.queryByText("Okta")).not.toBeInTheDocument(); + }); + + it("renders known icons and skips unknown types without crashing", async () => { + // Regression guard for #9991: an unknown type in the stack must not throw. + render( + , + ); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/icons/providers-badge/provider-type-icon.tsx b/ui/components/icons/providers-badge/provider-type-icon.tsx new file mode 100644 index 0000000000..c3915f7d40 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { type ComponentType, lazy, Suspense } from "react"; + +import { + Badge, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; +import { cn } from "@/lib/utils"; +import type { ProviderType } from "@/types/providers"; + +type IconProps = { width: number; height: number }; + +const IconPlaceholder = ({ width, height }: IconProps) => ( +
+); + +// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred +// chunk instead of being eagerly bundled wherever a selector is imported. +const AWSProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AWSProviderBadge, + })), +); +const AzureProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AzureProviderBadge, + })), +); +const GCPProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GCPProviderBadge, + })), +); +const KS8ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.KS8ProviderBadge, + })), +); +const M365ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.M365ProviderBadge, + })), +); +const GitHubProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GitHubProviderBadge, + })), +); +const GoogleWorkspaceProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GoogleWorkspaceProviderBadge, + })), +); +const IacProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.IacProviderBadge, + })), +); +const ImageProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.ImageProviderBadge, + })), +); +const OracleCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OracleCloudProviderBadge, + })), +); +const MongoDBAtlasProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.MongoDBAtlasProviderBadge, + })), +); +const AlibabaCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AlibabaCloudProviderBadge, + })), +); +const CloudflareProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.CloudflareProviderBadge, + })), +); +const OpenStackProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OpenStackProviderBadge, + })), +); +const VercelProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.VercelProviderBadge, + })), +); +const OktaProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OktaProviderBadge, + })), +); + +/** + * Single source of truth mapping each provider type to its human-readable + * label and (lazy) badge component. Shared by the account and provider-type + * selectors so both stay in sync on labels, icons, and sizing. + */ +export const PROVIDER_TYPE_DATA: Record< + ProviderType, + { label: string; icon: ComponentType } +> = { + aws: { label: "Amazon Web Services", icon: AWSProviderBadge }, + azure: { label: "Microsoft Azure", icon: AzureProviderBadge }, + gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge }, + kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge }, + m365: { label: "Microsoft 365", icon: M365ProviderBadge }, + github: { label: "GitHub", icon: GitHubProviderBadge }, + googleworkspace: { + label: "Google Workspace", + icon: GoogleWorkspaceProviderBadge, + }, + iac: { label: "Infrastructure as Code", icon: IacProviderBadge }, + image: { label: "Container Registry", icon: ImageProviderBadge }, + oraclecloud: { + label: "Oracle Cloud Infrastructure", + icon: OracleCloudProviderBadge, + }, + mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge }, + alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge }, + cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge }, + openstack: { label: "OpenStack", icon: OpenStackProviderBadge }, + vercel: { label: "Vercel", icon: VercelProviderBadge }, + okta: { label: "Okta", icon: OktaProviderBadge }, +}; + +interface ProviderTypeIconProps { + type: ProviderType; + size?: number; +} + +/** + * Renders a single provider-type badge with a sized placeholder fallback. + * + * Falls back to the placeholder for provider types missing from + * `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI + * build does not). The `type` is statically typed as `ProviderType`, so this + * only guards the runtime case — see #9991, which fixed the same crash class. + */ +export const ProviderTypeIcon = ({ + type, + size = 18, +}: ProviderTypeIconProps) => { + const data = PROVIDER_TYPE_DATA[type]; + if (!data) return ; + + const Icon = data.icon; + return ( + }> + + + ); +}; + +export interface ProviderTypeIconStackItem { + /** Stable React key (account id for accounts, provider type for types). */ + key: string; + type: ProviderType; + /** Text shown on hover to disambiguate the icon (e.g. an account UID). */ + tooltip?: string; +} + +interface ProviderTypeIconStackProps { + items: ProviderTypeIconStackItem[]; + max?: number; + size?: number; + className?: string; +} + +/** + * Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a + * Radix portal, so the tooltip is not clipped by the selector trigger and we do + * not need to portal it ourselves. `delayDuration` is set on the tooltip itself + * because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider` + * (delay 0), which would otherwise override an ancestor provider's delay. + */ +const IconWithTooltip = ({ + item, + size, +}: { + item: ProviderTypeIconStackItem; + size: number; +}) => { + const icon = ( + + + + ); + + if (!item.tooltip) return icon; + + return ( + + {icon} + {item.tooltip} + + ); +}; + +/** + * Renders up to `max` provider-type icons followed by a `+N` badge for the + * remainder. Each icon shows its `tooltip` on hover. Items are rendered as + * passed (one per selection) — callers decide whether to dedupe. + */ +export const ProviderTypeIconStack = ({ + items, + max = 3, + size = 18, + className, +}: ProviderTypeIconStackProps) => { + const visible = items.slice(0, max); + const overflow = items.slice(max); + const overflowLabel = overflow + .map((item) => item.tooltip) + .filter(Boolean) + .join(", "); + + return ( + + + {visible.map((item) => ( + + ))} + + {overflow.length > 0 && ( + + + + +{overflow.length} + + + {overflowLabel && ( + + {overflowLabel} + + )} + + )} + + ); +}; diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 7b3af4b018..23be644570 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -8,7 +8,10 @@ import { useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -279,11 +282,14 @@ export const S3IntegrationForm = ({ // Show configuration step (step 0 or editing configuration) if (isEditingConfig || currentStep === 0) { const providerOptions = providers.map((provider) => { - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: provider.attributes.connection.connected ? "Connected" : "Disconnected", diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index 8a62f62457..1c3b436832 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -10,7 +10,10 @@ import { useEffect, useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({ ? "Connected" : "Disconnected"; - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: isDisabled ? `${connectionLabel} (Already in use)` : connectionLabel, diff --git a/ui/components/providers/no-providers-added.tsx b/ui/components/providers/no-providers-added.tsx new file mode 100644 index 0000000000..0860f92d80 --- /dev/null +++ b/ui/components/providers/no-providers-added.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; + +import { InfoIcon } from "@/components/icons/Icons"; +import { Button, Card, CardContent } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; + +const NO_PROVIDERS_ADDED_ACTION = { + BUTTON: "button", + LINK: "link", +} as const; + +interface NoProvidersAddedBaseProps { + containerClassName?: string; +} + +interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON; + onOpenWizard: () => void; + href?: never; +} + +interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.LINK; + href: string; + onOpenWizard?: never; +} + +type NoProvidersAddedProps = + | NoProvidersAddedButtonProps + | NoProvidersAddedLinkProps; + +const renderCta = (props: NoProvidersAddedProps) => { + if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) { + return ( + + ); + } + + return ( + + ); +}; + +export const NoProvidersAdded = (props: NoProvidersAddedProps) => { + return ( +
+ + +
+ +

+ No Providers Configured +

+
+
+

+ No providers have been configured. Start by setting up a provider. +

+
+ + {renderCta(props)} +
+
+
+ ); +}; diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index 9263277ea9..c8b3baf596 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -1,11 +1,36 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + replaceMock: vi.fn(), + searchParamsValue: { current: "" }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/providers", + useRouter: () => ({ + refresh: refreshMock, + replace: replaceMock, + }), + useSearchParams: () => new URLSearchParams(searchParamsValue.current), +})); + +vi.mock("@/components/providers/table", () => ({ + SkeletonTableProviders: () =>
, +})); + vi.mock("@/components/providers/add-provider-button", () => ({ - AddProviderButton: () => , + AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => ( + + ), })); vi.mock("@/components/providers/muted-findings-config-button", () => ({ @@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({ })); vi.mock("@/components/providers/providers-filters", () => ({ - ProvidersFilters: () =>
Filters
, + ProvidersFilters: ({ actions }: { actions: ReactNode }) => ( +
+ Filters + {actions} +
+ ), })); vi.mock("@/components/providers/providers-accounts-table", () => ({ @@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({ })); vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: () =>
, + ProviderWizardModal: ({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ Provider wizard + +
+ ) : null, })); import { ProvidersAccountsView } from "./providers-accounts-view"; @@ -36,8 +80,55 @@ const metadata: MetaDataProps = { version: "latest", }; +const disconnectedProviders: ProviderProps[] = [ + { + id: "provider-1", + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production", + status: "completed", + resources: 0, + connection: { + connected: false, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, +]; + describe("ProvidersAccountsView", () => { - it("keeps the same vertical spacing between filters and table as other views", () => { + afterEach(() => { + vi.restoreAllMocks(); + searchParamsValue.current = ""; + window.history.replaceState({}, "", "/"); + }); + + it("shows a full page empty state without filters or table when there are no providers", () => { + // Given/When render( { />, ); + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: /no providers configured/i }), + ).toHaveClass("min-h-[calc(100dvh-28rem)]"); + expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument(); + expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the no providers CTA", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + }); + + it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + window.history.replaceState( + {}, + "", + "/providers?tab=connected&addProvider=true", + ); + // Spy only after the URL setup so we measure what the component does on mount. + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + + render( + , + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + expect(replaceStateSpy).not.toHaveBeenCalled(); + }); + + it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + // The URL is cleaned via the History API (no RSC refetch). We must NOT + // refresh/replace here: re-running the /providers Server Component on close + // read as a full page reload. The provider-creation actions already + // revalidatePath("/providers"), so the table is fresh behind the modal. + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/providers?tab=connected", + ); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("does not touch the URL or refetch when a manually opened wizard closes", async () => { + // Given: no addProvider param in the URL, wizard opened via the CTA. + searchParamsValue.current = ""; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When: open the wizard from the empty-state CTA, then close it. + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then: nothing to clean and no refresh — the creation actions own the + // data refresh via revalidatePath. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("keeps filters and table visible when providers are disconnected", () => { + // Given/When + render( + , + ); + + // Then expect(screen.getByTestId("providers-filters").parentElement).toHaveClass( "flex", "flex-col", "gap-6", ); expect(screen.getByTestId("providers-table")).toBeInTheDocument(); + expect( + screen.queryByText("No Providers Configured"), + ).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the normal Add Provider button", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /add provider/i })); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); }); }); diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index b53155d8c0..62322e2ccd 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -1,9 +1,11 @@ "use client"; +import { usePathname, useSearchParams } from "next/navigation"; import { useState } from "react"; import { AddProviderButton } from "@/components/providers/add-provider-button"; import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table"; import { ProvidersFilters } from "@/components/providers/providers-filters"; import { ProviderWizardModal } from "@/components/providers/wizard"; @@ -11,6 +13,10 @@ import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { + ADD_PROVIDER_SEARCH_PARAM, + ADD_PROVIDER_SEARCH_VALUE, +} from "@/lib/providers-navigation"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; @@ -29,7 +35,14 @@ export function ProvidersAccountsView({ providers, rows, }: ProvidersAccountsViewProps) { - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const hasNoProviders = providers.length === 0; + const shouldOpenProviderWizardFromUrl = + searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE; + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState( + () => shouldOpenProviderWizardFromUrl, + ); const [providerWizardInitialData, setProviderWizardInitialData] = useState< ProviderWizardInitialData | undefined >(undefined); @@ -52,38 +65,64 @@ export function ProvidersAccountsView({ const handleWizardOpenChange = (open: boolean) => { setIsProviderWizardOpen(open); - if (!open) { - setProviderWizardInitialData(undefined); - setOrgWizardInitialData(undefined); + if (open) return; + + setProviderWizardInitialData(undefined); + setOrgWizardInitialData(undefined); + + // Only clean the one-shot ?addProvider intent from the URL bar, via the + // History API so it does NOT trigger an RSC refetch. We must not refresh + // here: the provider-creation actions (addProvider / addCredentialsProvider + // / checkConnectionProvider) already revalidatePath("/providers"), so the + // table updates behind the modal. A router.refresh()/replace() on close + // re-ran the whole /providers Server Component, which read as a full reload. + if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) { + const params = new URLSearchParams(searchParams.toString()); + params.delete(ADD_PROVIDER_SEARCH_PARAM); + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `${pathname}?${query}` : pathname, + ); } }; return ( <> -
- - - openProviderWizard()} /> - - } + {hasNoProviders ? ( + openProviderWizard()} /> - -
+ ) : ( +
+ + + openProviderWizard()} /> + + } + /> + +
+ )} ); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 40644450c2..dea38e95a9 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + // When false, the caller skips the post-close router.refresh() and relies on + // the provider-creation actions' revalidatePath("/providers") to refresh the + // data. Defaults to true so standalone callers keep refreshing. + refreshOnClose?: boolean; } export function useProviderWizardController({ @@ -57,6 +61,7 @@ export function useProviderWizardController({ onOpenChange, initialData, orgInitialData, + refreshOnClose = true, }: UseProviderWizardControllerProps) { const router = useRouter(); const initialProviderId = initialData?.providerId ?? null; @@ -185,7 +190,9 @@ export function useProviderWizardController({ setProviderTypeHint(null); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); onOpenChange(false); - router.refresh(); + if (refreshOnClose) { + router.refresh(); + } }; const handleDialogOpenChange = (nextOpen: boolean) => { diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d731ca5786..2d0646d957 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -38,6 +38,7 @@ interface ProviderWizardModalProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + refreshOnClose?: boolean; } export function ProviderWizardModal({ @@ -45,6 +46,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }: ProviderWizardModalProps) { const { backToProviderFlow, @@ -72,6 +74,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }); const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; const { containerRef, sentinelRef, showScrollHint } = useScrollHint({ diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx deleted file mode 100644 index a108009c2d..0000000000 --- a/ui/components/scans/no-providers-added.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { Button, Card, CardContent } from "@/components/shadcn"; - -import { InfoIcon } from "../icons/Icons"; - -interface NoProvidersAddedProps { - onOpenWizard: () => void; -} - -export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => ( -
- - -
- -

- No Providers Configured -

-
-
-

- No providers have been configured. Start by setting up a provider. -

-
- - -
-
-
-); diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx index dc6748a785..deee8318e3 100644 --- a/ui/components/scans/scans-providers-empty-state.test.tsx +++ b/ui/components/scans/scans-providers-empty-state.test.tsx @@ -1,73 +1,43 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; -const { replaceMock, searchParamsValue } = vi.hoisted(() => ({ - replaceMock: vi.fn(), - searchParamsValue: { current: "" }, -})); - -vi.mock("next/navigation", () => ({ - usePathname: () => "/scans", - useRouter: () => ({ - replace: replaceMock, - }), - useSearchParams: () => new URLSearchParams(searchParamsValue.current), -})); - -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: ({ open }: { open: boolean }) => - open ?
Provider wizard
: null, -})); - vi.mock("./no-providers-connected", () => ({ NoProvidersConnected: () =>
No Connected Providers
, })); describe("ScansProvidersEmptyState", () => { - afterEach(() => { - vi.clearAllMocks(); - searchParamsValue.current = ""; - }); - - it("shows the add provider message and opens the provider wizard", async () => { - const user = userEvent.setup(); - + it("shows the add provider message with a providers page CTA", () => { + // Given/When render(); - expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); - - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); - }); - - it("clears the launch scan URL intent before opening the provider wizard", async () => { - // Given - searchParamsValue.current = "tab=completed&launchScan=true"; - const user = userEvent.setup(); - - render(); - - // When - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - // Then - expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", { - scroll: false, + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + const cta = screen.getByRole("link", { + name: /open add provider modal/i, }); - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + + expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF); + expect(cta.tagName).toBe("A"); + }); + + it("does not render the provider wizard in Scans", () => { + // Given/When + render(); + + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("shows the no connected providers message", () => { + // Given/When render(); + // Then expect(screen.getByText("No Connected Providers")).toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); diff --git a/ui/components/scans/scans-providers-empty-state.tsx b/ui/components/scans/scans-providers-empty-state.tsx index 7837dcea2c..09258320f9 100644 --- a/ui/components/scans/scans-providers-empty-state.tsx +++ b/ui/components/scans/scans-providers-empty-state.tsx @@ -1,12 +1,6 @@ -"use client"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; - -import { ProviderWizardModal } from "@/components/providers/wizard"; -import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation"; - -import { NoProvidersAdded } from "./no-providers-added"; import { NoProvidersConnected } from "./no-providers-connected"; interface ScansProvidersEmptyStateProps { @@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps { export function ScansProvidersEmptyState({ thereIsNoProviders, }: ScansProvidersEmptyStateProps) { - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); - - const openProviderWizard = () => { - if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) { - const params = new URLSearchParams(searchParams.toString()); - params.delete(LAUNCH_SCAN_SEARCH_PARAM); - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); - } - - setIsProviderWizardOpen(true); - }; - return ( <> {thereIsNoProviders ? ( - + ) : ( )} - ); } diff --git a/ui/dependency-log.json b/ui/dependency-log.json index e52d786972..76af7f296a 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -778,26 +778,26 @@ { "section": "devDependencies", "name": "@vitest/browser", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/browser-playwright", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/coverage-v8", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", @@ -978,10 +978,10 @@ { "section": "devDependencies", "name": "vitest", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", diff --git a/ui/lib/providers-navigation.ts b/ui/lib/providers-navigation.ts new file mode 100644 index 0000000000..7428eed540 --- /dev/null +++ b/ui/lib/providers-navigation.ts @@ -0,0 +1,3 @@ +export const ADD_PROVIDER_SEARCH_PARAM = "addProvider"; +export const ADD_PROVIDER_SEARCH_VALUE = "true"; +export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`; diff --git a/ui/package.json b/ui/package.json index 8466539961..a5c34094b0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -133,9 +133,9 @@ "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@vitejs/plugin-react": "5.1.2", - "@vitest/browser": "4.0.18", - "@vitest/browser-playwright": "4.0.18", - "@vitest/coverage-v8": "4.0.18", + "@vitest/browser": "4.1.8", + "@vitest/browser-playwright": "4.1.8", + "@vitest/coverage-v8": "4.1.8", "babel-plugin-react-compiler": "1.0.0", "dotenv": "16.6.1", "dotenv-expand": "12.0.3", @@ -158,7 +158,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "tailwindcss": "4.1.18", "typescript": "5.5.4", - "vitest": "4.0.18", + "vitest": "4.1.8", "vitest-browser-react": "2.0.4" }, "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 3996bbb8a8..6d44c180a1 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -325,14 +325,14 @@ importers: specifier: 5.1.2 version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) '@vitest/browser': - specifier: 4.0.18 - version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) '@vitest/browser-playwright': - specifier: 4.0.18 - version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -400,11 +400,11 @@ importers: specifier: 5.5.4 version: 5.5.4 vitest: - specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + specifier: 4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) vitest-browser-react: specifier: 2.0.4 - version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18) + version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8) packages: @@ -737,6 +737,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -4795,54 +4798,54 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser-playwright@4.0.18': - resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + '@vitest/browser-playwright@4.1.8': + resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} peerDependencies: playwright: '*' - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/browser@4.0.18': - resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + '@vitest/browser@4.1.8': + resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} peerDependencies: - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.8 + vitest: 4.1.8 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5048,8 +5051,8 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -5650,9 +5653,6 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -7246,10 +7246,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -7537,6 +7533,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -7829,8 +7826,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -8347,20 +8344,23 @@ packages: '@types/react-dom': optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -8374,6 +8374,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -9319,6 +9323,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.1': {} '@cfworker/json-schema@4.1.1': {} @@ -14261,29 +14267,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)': + '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)': dependencies: - '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) playwright: 1.56.1 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)': + '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)': dependencies: - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) - '@vitest/utils': 4.0.18 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/utils': 4.1.8 magic-string: 0.30.21 - pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -14291,60 +14297,62 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 + std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.8': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': @@ -14604,7 +14612,7 @@ snapshots: ast-types-flow@0.0.8: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.3: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -15273,8 +15281,6 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -17280,10 +17286,6 @@ snapshots: picomatch@4.0.4: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -17980,7 +17982,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -18487,31 +18489,31 @@ snapshots: terser: 5.47.1 yaml: 2.9.0 - vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18): + vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8): dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -18521,20 +18523,11 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.8 - '@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) jsdom: 27.4.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-keyname@2.2.8: {} diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 2bbbdeaeec..dd4692ed57 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -132,6 +132,24 @@ export async function addAWSProvider( await scansPage.verifyPageLoaded(); } +/** + * Waits for the providers page to settle and reports whether the data table is + * present. With zero providers the page renders a full-page empty state + * ("No Providers Configured") instead of the table, so callers must not assume + * the table is always there. + */ +async function providersTableVisibleOrEmptyState( + page: ProvidersPage, +): Promise { + const emptyState = page.page.getByRole("region", { + name: /no providers configured/i, + }); + await expect(page.providersTable.or(emptyState)).toBeVisible({ + timeout: 10000, + }); + return page.providersTable.isVisible().catch(() => false); +} + export async function deleteProviderIfExists( page: ProvidersPage, providerUID: string, @@ -140,7 +158,11 @@ export async function deleteProviderIfExists( // Navigate to providers page await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + // With zero providers the page shows the empty state, not the table, so there + // is nothing to delete. + if (!(await providersTableVisibleOrEmptyState(page))) { + return; + } const allRows = page.providersTable.locator("tbody tr"); @@ -180,7 +202,7 @@ export async function deleteProviderIfExists( // Provider not found, nothing to delete // Navigate back to providers page to ensure clean state await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); return; } @@ -217,7 +239,8 @@ export async function deleteProviderIfExists( // Wait for modal to close (this indicates deletion was initiated) await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Navigate back to providers page to ensure clean state + // Navigate back to providers page to ensure clean state. Deleting the last + // provider reveals the empty state instead of an empty table. await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); } diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 5b73269062..54545f965d 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage { name: /Adding A Provider|Update Provider Credentials/i, }); - // Button to add a new provider + // Button to add a new provider. When providers exist this is the filter-bar + // "Add Provider" control; with zero providers the page renders the empty + // state whose CTA is labelled "Open Add Provider modal" (button on + // /providers, link on /scans). Only one of these is ever in the DOM at once. this.addProviderButton = page .getByRole("button", { name: "Add Provider", @@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage { name: "Add Provider", exact: true, }), - ); + ) + .or(page.getByRole("button", { name: "Open Add Provider modal" })) + .or(page.getByRole("link", { name: "Open Add Provider modal" })); // Table displaying existing providers this.providersTable = page.getByRole("table"); From eb7949c884f66cb46892eedeb7bd6012bd4d4123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 3 Jun 2026 17:03:12 +0200 Subject: [PATCH 008/129] fix(ui): show delete user action only for the current user (#11447) Co-authored-by: Pepe Fagoaga --- .../user-guide/tutorials/prowler-app-rbac.mdx | 6 +- ui/CHANGELOG.md | 1 + ui/app/(prowler)/users/page.tsx | 3 + .../table/data-table-row-actions.test.tsx | 159 ++++++++++++++++++ .../users/table/data-table-row-actions.tsx | 59 ++++--- 5 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 ui/components/users/table/data-table-row-actions.test.tsx diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index c6319591cf..cabea5bd35 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -47,7 +47,11 @@ Follow these steps to remove a user of your account: 1. Navigate to **Users** from the side menu. 2. Click the delete button of your current user. -> **Note: Each user will be able to delete himself and not others, regardless of his permissions.** +> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.** + +Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row. + +To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**. Remove User diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index db38de5fd3..8b2ba973aa 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) +- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447) ### 🔐 Security diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 4b26c0baf2..fa1e838872 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -109,6 +109,9 @@ const SSRDataTable = async ({ roles, canBeExpelled, currentTenantId: canBeExpelled ? currentTenantId : undefined, + // Users may only delete their own account; gate the delete action so the + // UI matches the backend rule and never offers an action that would fail. + isCurrentUser: user.id === currentUserId, }; }); diff --git a/ui/components/users/table/data-table-row-actions.test.tsx b/ui/components/users/table/data-table-row-actions.test.tsx new file mode 100644 index 0000000000..39a6c95f8b --- /dev/null +++ b/ui/components/users/table/data-table-row-actions.test.tsx @@ -0,0 +1,159 @@ +import { Row } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +// The forms pull in server actions (`@/actions/users/users`) that can't run in +// jsdom, so stub them with identifiable markers to assert which modal opens. +vi.mock("../forms", () => ({ + DeleteForm: ({ userId }: { userId: string }) => ( +
delete-form:{userId}
+ ), + EditForm: ({ userId }: { userId: string }) => ( +
edit-form:{userId}
+ ), + ExpelUserForm: ({ userId }: { userId: string }) => ( +
expel-form:{userId}
+ ), +})); + +import { DataTableRowActions } from "./data-table-row-actions"; + +interface RowOptions { + id?: string; + isCurrentUser?: boolean; + canBeExpelled?: boolean; + currentTenantId?: string; +} + +const createRow = ({ + id = "user-1", + isCurrentUser, + canBeExpelled, + currentTenantId, +}: RowOptions = {}) => + ({ + original: { + id, + attributes: { + name: "Jane Doe", + email: "jane@example.com", + company_name: "Acme", + role: { name: "admin" }, + }, + isCurrentUser, + canBeExpelled, + currentTenantId, + }, + }) as unknown as Row<{ id: string }>; + +const openMenu = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: "Open actions menu" })); +}; + +describe("DataTableRowActions (users)", () => { + it("always renders the Edit User action", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.getByText("Edit User")).toBeInTheDocument(); + }); + + it("shows Delete User only for the current user's row", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.getByText("Delete User")).toBeInTheDocument(); + expect(screen.getByText("Danger zone")).toBeInTheDocument(); + }); + + it("does NOT show Delete User for another user's row", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("does NOT show Delete User when isCurrentUser is undefined", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + + // Only the non-destructive Edit action remains. + expect(screen.getByText("Edit User")).toBeInTheDocument(); + expect(screen.queryByText("Danger zone")).not.toBeInTheDocument(); + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + expect( + screen.queryByText("Expel from organization"), + ).not.toBeInTheDocument(); + }); + + it("shows Expel but not Delete User for an expellable, non-current user", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + + expect(screen.getByText("Danger zone")).toBeInTheDocument(); + expect(screen.getByText("Expel from organization")).toBeInTheDocument(); + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("renders Delete User with destructive styling", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + const menuItem = screen + .getByText("Delete User") + .closest("[role='menuitem']"); + expect(menuItem).toBeInTheDocument(); + expect(menuItem).toHaveClass("text-text-error-primary"); + }); + + it("opens the delete confirmation modal when Delete User is selected", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + await user.click(screen.getByText("Delete User")); + + expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument(); + expect(screen.getByTestId("delete-form")).toHaveTextContent( + "delete-form:user-42", + ); + }); +}); diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index 3bc8e6a0c8..29c59566da 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -29,6 +29,7 @@ interface UserRowData { attributes?: UserRowAttributes; canBeExpelled?: boolean; currentTenantId?: string; + isCurrentUser?: boolean; } interface DataTableRowActionsProps { @@ -57,6 +58,10 @@ export function DataTableRowActions({ row.original.canBeExpelled === true && !!row.original.currentTenantId; const currentTenantId = row.original.currentTenantId; + // A user can only delete their own account (enforced by the backend), so the + // delete action is shown exclusively for the current user's row. + const canDeleteUser = row.original.isCurrentUser === true; + return ( <> ({ setIsOpen={setIsEditOpen} /> - - - + {canDeleteUser && ( + + + + )} {canExpelUser && currentTenantId && ( ({ label="Edit User" onSelect={() => setIsEditOpen(true)} /> - - {canExpelUser && ( - + {(canExpelUser || canDeleteUser) && ( + + {canExpelUser && ( + + )}
From bcd282d3d0d17655ea0d799605268d362e10f1dc Mon Sep 17 00:00:00 2001 From: Oleksandr_Sanin Date: Thu, 4 Jun 2026 12:07:01 +0200 Subject: [PATCH 009/129] fix(gcp): honour org-level aggregated sinks in logging_sink_created check (#11355) Signed-off-by: Oleksandr Sanin Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 8 + .../gcp/services/logging/logging_service.py | 34 ++++ .../logging_sink_created.py | 61 ++++-- .../services/logging/logging_service_test.py | 73 ++++++- .../logging_sink_created_test.py | 178 +++++++++++++++++- 5 files changed, 336 insertions(+), 18 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d16f24ca99..db7c87f4c7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,6 +11,14 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +## [5.29.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) + +--- + ## [5.29.1] (Prowler v5.29.1) ### 🐞 Fixed diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 637c8782b2..2459895c4c 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -12,6 +12,7 @@ class Logging(GCPService): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +40,38 @@ class Logging(GCPService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_org_sinks(self): + """Fetch org-level sinks with includeChildren so child projects are not falsely failed.""" + org_ids = set() + for project in self.projects.values(): + if project.organization: + org_ids.add(project.organization.id) + + for org_id in org_ids: + try: + request = self.client.sinks().list(parent=f"organizations/{org_id}") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + + for sink in response.get("sinks", []): + self.sinks.append( + Sink( + name=sink["name"], + destination=sink["destination"], + filter=sink.get("filter", "all"), + project_id=f"organizations/{org_id}", + include_children=sink.get("includeChildren", False), + ) + ) + + request = self.client.sinks().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_metrics(self): for project_id in self.project_ids: try: @@ -76,6 +109,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py index 30104a050d..a7846e3dd8 100644 --- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py +++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py @@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client class logging_sink_created(Check): def execute(self) -> Check_Report_GCP: findings = [] + + # Map project_id -> sink for direct project-level sinks projects_with_logging_sink = {} for sink in logging_client.sinks: - if sink.filter == "all": + if sink.filter == "all" and not sink.include_children: projects_with_logging_sink[sink.project_id] = sink + # Collect org resource names that have a covering sink (includeChildren=True) + covering_org_sinks = {} + for sink in logging_client.sinks: + if sink.filter == "all" and sink.include_children: + covering_org_sinks[sink.project_id] = sink + for project in logging_client.project_ids: - if project not in projects_with_logging_sink.keys(): - project_obj = logging_client.projects.get(project) - report = Check_Report_GCP( - metadata=self.metadata(), - resource=project_obj, - resource_id=project, - project_id=project, - location=logging_client.region, - resource_name=(getattr(project_obj, "name", None) or "GCP Project"), - ) - report.status = "FAIL" - report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." - findings.append(report) - else: + project_obj = logging_client.projects.get(project) + + # Determine whether this project is covered by an org-level sink + org = getattr(project_obj, "organization", None) if project_obj else None + org_resource = f"organizations/{org.id}" if org else None + covering_sink = ( + covering_org_sinks.get(org_resource) if org_resource else None + ) + + if project in projects_with_logging_sink: sink = projects_with_logging_sink[project] sink_name = getattr(sink, "name", None) or "unknown" report = Check_Report_GCP( @@ -40,4 +44,31 @@ class logging_sink_created(Check): report.status = "PASS" report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." findings.append(report) + elif covering_sink: + sink_name = getattr(covering_sink, "name", None) or "unknown" + report = Check_Report_GCP( + metadata=self.metadata(), + resource=covering_sink, + resource_id=sink_name, + project_id=project, + location=logging_client.region, + resource_name=( + sink_name if sink_name != "unknown" else "Logging Sink" + ), + ) + report.status = "PASS" + report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}." + findings.append(report) + else: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=project_obj, + resource_id=project, + project_id=project, + location=logging_client.region, + resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + ) + report.status = "FAIL" + report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." + findings.append(report) return findings diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 0396130c2f..49368d0289 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +66,74 @@ class TestLoggingService: == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID + + def test_org_sinks_fetched_when_project_has_organization(self): + """_get_org_sinks() appends org-level sinks when projects have an org.""" + from prowler.providers.gcp.models import GCPOrganization, GCPProject + + org_id = "999888777" + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"), + ) + } + + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = { + "sinks": [ + { + "name": "org-sink", + "destination": "storage.googleapis.com/org-bucket", + "filter": "all", + "includeChildren": True, + } + ] + } + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = {"metrics": []} + mock_client.projects().metrics().list_next.return_value = None + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=mock_client, + ), + ): + logging_svc = Logging(provider) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" + ] + assert len(org_sinks) == 1 + assert org_sinks[0].name == "org-sink" + assert org_sinks[0].include_children is True + assert org_sinks[0].filter == "all" + + def test_org_sinks_skipped_when_no_organization(self): + """_get_org_sinks() adds nothing when projects have no organization.""" + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id.startswith("organizations/") + ] + assert org_sinks == [] diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index b9c6481d22..6ced615f65 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.models import GCPOrganization, GCPProject from tests.providers.gcp.gcp_fixtures import ( GCP_EU1_LOCATION, GCP_PROJECT_ID, @@ -268,6 +268,7 @@ class Test_logging_sink_created: sink.name = None sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -311,9 +312,10 @@ class Test_logging_sink_created: ) # Create a MagicMock sink object without name attribute - sink = MagicMock(spec=["filter", "project_id"]) + sink = MagicMock(spec=["filter", "project_id", "include_children"]) sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -336,3 +338,175 @@ class Test_logging_sink_created: assert result[0].resource_id == "unknown" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_with_include_children_passes(self): + """Projects covered by an org-level sink with includeChildren=True should PASS.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "org-sink" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_without_include_children_fails(self): + """Projects NOT covered by includeChildren should still FAIL if no direct project sink.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink-no-children", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=False, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_project_sink_takes_precedence_over_org_sink(self): + """A direct project sink should be reported even when an org-level sink also covers the project.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="project-sink", + destination="storage.googleapis.com/project-bucket", + filter="all", + project_id=GCP_PROJECT_ID, + ), + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ), + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "project-sink" + assert result[0].project_id == GCP_PROJECT_ID From 3a3d9d61462983f5856e4b4bf418d898141b69ef Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:31:16 +0200 Subject: [PATCH 010/129] chore(ui): type process.env via ambient NodeJS.ProcessEnv (#11328) Co-authored-by: Pablo F.G --- ui/__tests__/msw/handlers/attack-paths.ts | 2 +- ui/types/env.d.ts | 125 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 ui/types/env.d.ts diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index b17c6c0596..39076666dd 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL!; +const API = process.env.NEXT_PUBLIC_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts new file mode 100644 index 0000000000..c0b5b10a66 --- /dev/null +++ b/ui/types/env.d.ts @@ -0,0 +1,125 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + // Runtime (Node / Next.js) + NODE_ENV: "development" | "production" | "test"; + NEXT_RUNTIME?: "nodejs" | "edge"; + + // Public client config + NEXT_PUBLIC_API_BASE_URL: string; + NEXT_PUBLIC_API_DOCS_URL?: string; + NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false"; + NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string; + NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string; + NEXT_PUBLIC_SENTRY_DSN?: string; + NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string; + + // Auth (NextAuth) + AUTH_URL: string; + AUTH_SECRET: string; + AUTH_TRUST_HOST?: "true" | "false"; + NEXTAUTH_URL?: string; + + // Sentry (server / build) + SENTRY_DSN?: string; + SENTRY_ENVIRONMENT?: string; + SENTRY_RELEASE?: string; + SENTRY_ORG?: string; + SENTRY_PROJECT?: string; + SENTRY_AUTH_TOKEN?: string; + + // Social OAuth + SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string; + SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string; + SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string; + SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string; + SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string; + SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string; + + // Feature integrations + PROWLER_MCP_SERVER_URL?: string; + // JSON-encoded array, parsed in actions/feeds + RSS_FEED_SOURCES?: string; + + // Environment detection + CI?: string; + DOCKER?: string; + KUBERNETES_SERVICE_HOST?: string; + + // E2E test credentials (Playwright only) + E2E_ADMIN_USER?: string; + E2E_ADMIN_PASSWORD?: string; + E2E_NEW_USER_PASSWORD?: string; + E2E_MANAGE_CLOUD_PROVIDERS_USER?: string; + E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string; + E2E_INVITE_AND_MANAGE_USERS_USER?: string; + E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string; + E2E_UNLIMITED_VISIBILITY_USER?: string; + E2E_UNLIMITED_VISIBILITY_PASSWORD?: string; + E2E_MANAGE_INTEGRATIONS_USER?: string; + E2E_MANAGE_INTEGRATIONS_PASSWORD?: string; + E2E_MANAGE_ACCOUNT_USER?: string; + E2E_MANAGE_ACCOUNT_PASSWORD?: string; + E2E_MANAGE_SCANS_USER?: string; + E2E_MANAGE_SCANS_PASSWORD?: string; + E2E_ORGANIZATION_ID?: string; + + // E2E AWS + E2E_AWS_PROVIDER_ACCOUNT_ID?: string; + E2E_AWS_PROVIDER_ACCESS_KEY?: string; + E2E_AWS_PROVIDER_SECRET_KEY?: string; + E2E_AWS_PROVIDER_ROLE_ARN?: string; + E2E_AWS_ORGANIZATION_ID?: string; + E2E_AWS_ORGANIZATION_ROLE_ARN?: string; + + // E2E Azure + E2E_AZURE_SUBSCRIPTION_ID?: string; + E2E_AZURE_CLIENT_ID?: string; + E2E_AZURE_SECRET_ID?: string; + E2E_AZURE_TENANT_ID?: string; + + // E2E Microsoft 365 + E2E_M365_DOMAIN_ID?: string; + E2E_M365_CLIENT_ID?: string; + E2E_M365_TENANT_ID?: string; + E2E_M365_SECRET_ID?: string; + E2E_M365_CERTIFICATE_CONTENT?: string; + + // E2E GCP + E2E_GCP_PROJECT_ID?: string; + E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string; + + // E2E Kubernetes + E2E_KUBERNETES_CONTEXT?: string; + E2E_KUBERNETES_KUBECONFIG_PATH?: string; + + // E2E GitHub + E2E_GITHUB_USERNAME?: string; + E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string; + E2E_GITHUB_APP_ID?: string; + E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string; + E2E_GITHUB_ORGANIZATION?: string; + E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string; + + // E2E Oracle Cloud + E2E_OCI_TENANCY_ID?: string; + E2E_OCI_USER_ID?: string; + E2E_OCI_FINGERPRINT?: string; + E2E_OCI_KEY_CONTENT?: string; + E2E_OCI_REGION?: string; + + // E2E Alibaba Cloud + E2E_ALIBABACLOUD_ACCOUNT_ID?: string; + E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string; + E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string; + E2E_ALIBABACLOUD_ROLE_ARN?: string; + + // E2E Google Workspace + E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string; + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string; + E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string; + } + } +} + +export {}; From a5bc226f1141436e71a5c7e150df448fe772969d Mon Sep 17 00:00:00 2001 From: Aline Almeida Date: Fri, 5 Jun 2026 12:07:30 +0200 Subject: [PATCH 011/129] fix(gcp): pass iam_service_account_unused for disabled service accounts (#11467) --- prowler/CHANGELOG.md | 1 + .../providers/gcp/services/iam/iam_service.py | 2 + .../iam_service_account_unused.py | 7 ++- .../iam_service_account_unused_test.py | 57 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index db7c87f4c7..ec2e68c30e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) --- diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 13e96276e7..987a86068c 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -37,6 +37,7 @@ class IAM(GCPService): display_name=account.get("displayName", ""), project_id=project_id, uniqueId=account.get("uniqueId", ""), + disabled=account.get("disabled", False), ) ) @@ -102,6 +103,7 @@ class ServiceAccount(BaseModel): keys: list[Key] = [] project_id: str uniqueId: str + disabled: bool = False class AccessApproval(GCPService): diff --git a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py index 12440aff25..912237b9e5 100644 --- a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py +++ b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py @@ -19,7 +19,12 @@ class iam_service_account_unused(Check): resource_id=account.email, location=iam_client.region, ) - if account.uniqueId in sa_ids_used: + if account.disabled: + report.status = "PASS" + report.status_extended = ( + f"Service Account {account.email} is disabled and cannot be used." + ) + elif account.uniqueId in sa_ids_used: report.status = "PASS" report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days." else: diff --git a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py index d76200734d..79c0eb23c3 100644 --- a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py +++ b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py @@ -179,3 +179,60 @@ class Test_iam_service_account_unused: assert result[1].project_id == GCP_PROJECT_ID assert result[1].location == GCP_US_CENTER1_LOCATION assert result[1].resource == iam_client.service_accounts[1] + + def test_iam_service_account_disabled(self): + iam_client = mock.MagicMock() + monitoring_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client", + new=iam_client, + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import ServiceAccount + from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import ( + iam_service_account_unused, + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + + iam_client.service_accounts = [ + ServiceAccount( + name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com", + email="disabled-sa@my-project.iam.gserviceaccount.com", + display_name="Disabled service account", + keys=[], + project_id=GCP_PROJECT_ID, + uniqueId="999888877776666", + disabled=True, + ) + ] + + # The account is absent from the usage metrics, so a non-disabled + # account here would FAIL. Being disabled must take precedence and + # PASS, since a disabled account cannot authenticate or be used. + monitoring_client.sa_api_metrics = set() + monitoring_client.audit_config = {"max_unused_account_days": 30} + + check = iam_service_account_unused() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used." + ) + assert result[0].resource_id == iam_client.service_accounts[0].email + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].resource == iam_client.service_accounts[0] From d4bbc8b5adbfa1139b152aad731597bf540feace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 5 Jun 2026 13:26:28 +0200 Subject: [PATCH 012/129] fix(jira): avoid 400 INVALID_INPUT on findings with empty field (#11474) --- prowler/CHANGELOG.md | 1 + prowler/lib/outputs/jira/jira.py | 16 +++++- tests/lib/outputs/jira/jira_test.py | 83 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ec2e68c30e..f98b551bce 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) --- diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index ed8f7faab0..f7f31666e1 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -229,7 +229,9 @@ class MarkdownToADFConverter: return node def _paragraph_with_text(self, text: str) -> Dict: - return {"type": "paragraph", "content": [self._create_text_node(text, None)]} + # ADF forbids empty text nodes; emit an empty paragraph instead. + content = [self._create_text_node(text, None)] if text else [] + return {"type": "paragraph", "content": content} @staticmethod def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None: @@ -1118,6 +1120,18 @@ class Jira: tenant_info: str = "", ) -> dict: + # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT. + def _safe(value: str) -> str: + return value if (value and value.strip()) else "-" + + check_id = _safe(check_id) + check_title = _safe(check_title) + status_extended = _safe(status_extended) + provider = _safe(provider) + region = _safe(region) + resource_uid = _safe(resource_uid) + resource_name = _safe(resource_name) + table_rows = [ { "type": "tableRow", diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 03656891c8..788d6ba7c9 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1004,6 +1004,89 @@ class TestJiraIntegration: for mark in node.get("marks", []) ) + @staticmethod + def _find_empty_text_nodes(node) -> List[str]: + # ADF forbids empty text nodes; collect any to assert the document is valid. + empties: List[str] = [] + + def walk(current) -> None: + if isinstance(current, dict): + if current.get("type") == "text" and current.get("text", "") == "": + empties.append(current.get("text", "")) + for value in current.values(): + walk(value) + elif isinstance(current, list): + for item in current: + walk(item) + + walk(node) + return empties + + def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self): + # A resource without a name (e.g. an AWS-managed IAM policy) used to emit an + # empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT. + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="CRITICAL", + severity_color="#FF0000", + status="FAIL", + status_color="#FF0000", + status_extended="Some status", + provider="aws", + region="eu-west-1", + resource_uid="arn:aws:iam::aws:policy/AdministratorAccess", + resource_name="", + recommendation_text="", + ) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + resource_name_row = self._find_table_row(table["content"], "Resource Name") + value_cell = resource_name_row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + + @pytest.mark.parametrize( + "field, header", + [ + ("check_id", "Check Id"), + ("check_title", "Check Title"), + ("status_extended", "Status Extended"), + ("provider", "Provider"), + ("region", "Region"), + ("resource_uid", "Resource UID"), + ("resource_name", "Resource Name"), + ], + ) + def test_get_adf_description_empty_plain_text_fields_render_placeholder( + self, field, header + ): + base_kwargs = dict( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + status_extended="Some status", + provider="aws", + region="us-east-1", + resource_uid="resource-1", + resource_name="resource-name", + recommendation_text="", + ) + base_kwargs[field] = "" + + adf_description = self.jira_integration.get_adf_description(**base_kwargs) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + row = self._find_table_row(table["content"], header) + value_cell = row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] From a7d180ea5bdaba6e1899265fdfd4ff4b76b490b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 5 Jun 2026 13:28:31 +0200 Subject: [PATCH 013/129] feat(dashboard): add AWS AI Security Framework compliance view (#11475) --- .../aws_ai_security_framework_aws.py | 27 +++++++++++++++++++ prowler/CHANGELOG.md | 1 + 2 files changed, 28 insertions(+) create mode 100644 dashboard/compliance/aws_ai_security_framework_aws.py diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py new file mode 100644 index 0000000000..ece9bdf9cb --- /dev/null +++ b/dashboard/compliance/aws_ai_security_framework_aws.py @@ -0,0 +1,27 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + ) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f98b551bce..5d180cb4e3 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) +- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470) --- From 6f172a5c19886ee43e9ba1b5c19d7ce11ff6aaa1 Mon Sep 17 00:00:00 2001 From: potato-20 <164017049+potato-20@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:56:07 +0530 Subject: [PATCH 014/129] feat(elbv2): add elbv2_alb_drop_invalid_header_fields_enabled check (FSBP ELB.4) (#11471) Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 1 + ...ndational_security_best_practices_aws.json | 4 +- .../__init__.py | 0 ...nvalid_header_fields_enabled.metadata.json | 40 +++ ..._alb_drop_invalid_header_fields_enabled.py | 27 ++ ...drop_invalid_header_fields_enabled_test.py | 254 ++++++++++++++++++ 6 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py create mode 100644 tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5d180cb4e3..6922846b0d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) --- diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json index a64a421c8a..cea7ad1655 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -1863,7 +1863,9 @@ "Id": "ELB.4", "Name": "Application load balancers should be configured to drop HTTP headers", "Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.", - "Checks": [], + "Checks": [ + "elbv2_alb_drop_invalid_header_fields_enabled" + ], "Attributes": [ { "ItemId": "ELB.4", diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json new file mode 100644 index 0000000000..0293501578 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled", + "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.", + "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields", + "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true", + "NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```", + "Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.", + "Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"\" {\n name = \"\"\n load_balancer_type = \"application\"\n subnets = [\"\", \"\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```" + }, + "Recommendation": { + "Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.", + "Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py new file mode 100644 index 0000000000..86740a253a --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_alb_drop_invalid_header_fields_enabled(Check): + def execute(self): + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.type == "application": + report = Check_Report_AWS( + metadata=self.metadata(), + resource=lb, + ) + report.status = "PASS" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is configured to drop invalid " + "header fields." + ) + if lb.drop_invalid_header_fields != "true": + report.status = "FAIL" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is not configured to drop " + "invalid header fields." + ) + findings.append(report) + + return findings diff --git a/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py new file mode 100644 index 0000000000..40d8d91f0d --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py @@ -0,0 +1,254 @@ +from importlib import import_module +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_EU_WEST_1_AZA, + AWS_REGION_EU_WEST_1_AZB, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.elbv2." + "elbv2_alb_drop_invalid_header_fields_enabled." + "elbv2_alb_drop_invalid_header_fields_enabled" +) +ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client" +GLOBAL_PROVIDER_PATCH = ".".join( + [ + "prowler.providers.common.provider.Provider", + "get_global_provider", + ] +) +PASS_STATUS_EXTENDED = " ".join( + [ + "ELBv2 ALB my-lb is configured to drop invalid", + "header fields.", + ] +) +FAIL_STATUS_EXTENDED = ( + "ELBv2 ALB my-lb is not configured to drop invalid header fields." +) + + +def get_check_class(): + return getattr( + import_module(CHECK_MODULE), + "elbv2_alb_drop_invalid_header_fields_enabled", + ) + + +class Test_elbv2_alb_drop_invalid_header_fields_enabled: + @mock_aws + def test_elb_no_balancers(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_elbv2_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "true", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == PASS_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_not_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == FAIL_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_network_load_balancer_ignored(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + + conn.create_load_balancer( + Name="my-nlb", + Subnets=[subnet1.id], + Scheme="internal", + Type="network", + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 From 5a2226c02ccc2bdd2da567f09762b2d3f681b3ca Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:54:51 +0200 Subject: [PATCH 015/129] fix(ui): preserve active tab styling with tooltips (#11493) --- ui/CHANGELOG.md | 8 ++++++++ ui/components/shadcn/tabs/tabs.test.tsx | 27 +++++++++++++++++++++++++ ui/components/shadcn/tabs/tabs.tsx | 4 ++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 ui/components/shadcn/tabs/tabs.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8b2ba973aa..66d908b8fd 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,14 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.29.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Finding drawer tabs now keep the active tab text and underline styling when tooltip state changes [(#11493)](https://github.com/prowler-cloud/prowler/pull/11493) + +--- + ## [1.29.2] (Prowler v5.29.2) ### 🔄 Changed diff --git a/ui/components/shadcn/tabs/tabs.test.tsx b/ui/components/shadcn/tabs/tabs.test.tsx new file mode 100644 index 0000000000..8d9a54816d --- /dev/null +++ b/ui/components/shadcn/tabs/tabs.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Tabs, TabsList, TabsTrigger } from "./tabs"; + +describe("TabsTrigger", () => { + it("keeps active styling available when rendered with a tooltip", () => { + render( + + + + Overview + + + Remediation + + + , + ); + + const activeTrigger = screen.getByRole("tab", { name: "Overview" }); + + expect(activeTrigger).toHaveAttribute("aria-selected", "true"); + expect(activeTrigger).toHaveClass("aria-selected:text-slate-900"); + expect(activeTrigger).toHaveClass("aria-selected:after:scale-x-100"); + }); +}); diff --git a/ui/components/shadcn/tabs/tabs.tsx b/ui/components/shadcn/tabs/tabs.tsx index 92ca5575c8..22379000d6 100644 --- a/ui/components/shadcn/tabs/tabs.tsx +++ b/ui/components/shadcn/tabs/tabs.tsx @@ -18,9 +18,9 @@ const TRIGGER_STYLES = { border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]", text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", active: - "data-[state=active]:text-slate-900 dark:data-[state=active]:text-white", + "data-[state=active]:text-slate-900 aria-selected:text-slate-900 dark:data-[state=active]:text-white dark:aria-selected:text-white", underline: - "after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0", + "after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 aria-selected:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0", focus: "focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950", icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", From 28b045302f2c623d944e83e2a87f0da7f521c760 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Mon, 8 Jun 2026 13:30:18 +0200 Subject: [PATCH 016/129] fix(api): create Neo4j driver lazily so an outage can't block API startup (#11491) --- api/CHANGELOG.md | 8 +++ api/src/backend/api/apps.py | 40 ++----------- api/src/backend/api/attack_paths/database.py | 22 ++++++-- api/src/backend/api/tests/test_apps.py | 56 ++++--------------- .../api/tests/test_attack_paths_database.py | 43 ++++++++++++-- 5 files changed, 81 insertions(+), 88 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 4f4d3426f6..e9dac993ce 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -21,6 +21,14 @@ All notable changes to the **Prowler API** are documented in this file. --- +## [1.30.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491) + +--- + ## [1.30.1] (Prowler v5.29.1) ### 🐞 Fixed diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 3209f75888..6c3a4ec2d9 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,12 +1,14 @@ import logging import os import sys + from pathlib import Path +from django.apps import AppConfig +from django.conf import settings + from config.custom_logging import BackendLogger from config.env import env -from django.apps import AppConfig -from django.conf import settings logger = logging.getLogger(BackendLogger.API) @@ -30,7 +32,6 @@ class ApiConfig(AppConfig): def ready(self): from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 - from api.attack_paths import database as graph_database # Generate required cryptographic keys if not present, but only if: # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app @@ -41,37 +42,8 @@ class ApiConfig(AppConfig): ): self._ensure_crypto_keys() - # Commands that don't need Neo4j - SKIP_NEO4J_DJANGO_COMMANDS = [ - "makemigrations", - "migrate", - "pgpartition", - "check", - "help", - "showmigrations", - "check_and_fix_socialaccount_sites_migration", - ] - - # Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook) - if getattr(settings, "TESTING", False) or ( - len(sys.argv) > 1 - and ( - ( - "manage.py" in sys.argv[0] - and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS - ) - or "celery" in sys.argv[0] - ) - ): - logger.info( - "Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)" - ) - - else: - graph_database.init_driver() - - # Neo4j driver is initialized at API startup (see api.attack_paths.database) - # It remains lazy for Celery workers and selected Django commands + # Neo4j driver is created lazily on first use (see api.attack_paths.database). + # App init never contacts Neo4j, so a Neo4j outage cannot block API startup. def _ensure_crypto_keys(self): """ diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f5fddd0613..d5cc1698a7 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,22 +1,24 @@ import atexit import logging import threading + from contextlib import contextmanager from typing import Any, Iterator from uuid import UUID import neo4j import neo4j.exceptions + from config.env import env from django.conf import settings + +from api.attack_paths.retryable_session import RetryableSession from tasks.jobs.attack_paths.config import ( BATCH_SIZE, PROVIDER_RESOURCE_LABEL, get_provider_label, ) -from api.attack_paths.retryable_session import RetryableSession - # Without this Celery goes crazy with Neo4j logging logging.getLogger("neo4j").setLevel(logging.ERROR) logging.getLogger("neo4j").propagate = False @@ -28,6 +30,9 @@ READ_QUERY_TIMEOUT_SECONDS = env.int( "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 ) MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250) +# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be +# the longer of the two (it may include opening a new connection). +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) READ_EXCEPTION_CODES = [ "Neo.ClientError.Statement.AccessMode", @@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver: uri = get_uri() config = settings.DATABASES["neo4j"] - _driver = neo4j.GraphDatabase.driver( + driver = neo4j.GraphDatabase.driver( uri, auth=(config["USER"], config["PASSWORD"]), keep_alive=True, max_connection_lifetime=7200, + connection_timeout=CONNECTION_TIMEOUT, connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, max_connection_pool_size=50, ) - _driver.verify_connectivity() + # Publish the singleton only after connectivity is verified so a + # failed probe does not leave an unverified driver behind. Close the + # driver on failure so a repeatedly-probed outage cannot leak pools. + try: + driver.verify_connectivity() + except Exception: + driver.close() + raise + _driver = driver # Register cleanup handler (only runs once since we're inside the _driver is None block) atexit.register(close_driver) diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 5889b4e2cb..2f5b55a6e2 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -182,23 +182,19 @@ def _make_app(): return ApiConfig("api", api) -def test_ready_initializes_driver_for_api_process(monkeypatch): +@pytest.mark.parametrize( + "argv", + [ + ["gunicorn"], + ["celery", "-A", "api"], + ["manage.py", "migrate"], + ], + ids=["api", "celery", "manage_py"], +) +def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv): + """ready() must never contact Neo4j; the driver is created lazily on first use.""" config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_called_once() - - -def test_ready_skips_driver_for_celery(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["celery", "-A", "api"]) + _set_argv(monkeypatch, argv) _set_testing(monkeypatch, False) with ( @@ -208,31 +204,3 @@ def test_ready_skips_driver_for_celery(monkeypatch): config.ready() init_driver.assert_not_called() - - -def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["manage.py", "migrate"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() - - -def test_ready_skips_driver_when_testing(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, True) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 8828d23911..3a29a1007d 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -1,15 +1,16 @@ """ Tests for Neo4j database lazy initialization. -The Neo4j driver connects on first use by default. API processes may -eagerly initialize the driver during app startup, while Celery workers -remain lazy. These tests validate the database module behavior itself. +The Neo4j driver is created on first use for every process type; app startup +never contacts Neo4j. These tests validate the database module behavior itself. """ import threading + from unittest.mock import MagicMock, patch import neo4j +import neo4j.exceptions import pytest import api.attack_paths.database as db_module @@ -59,6 +60,32 @@ class TestLazyInitialization: assert result is mock_driver assert db_module._driver is mock_driver + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_leaves_driver_none_when_verify_fails( + self, mock_driver_factory, mock_settings + ): + """A failed verify_connectivity() must not publish or leak the driver.""" + mock_driver = MagicMock() + mock_driver.verify_connectivity.side_effect = ( + neo4j.exceptions.ServiceUnavailable("down") + ) + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + with pytest.raises(neo4j.exceptions.ServiceUnavailable): + db_module.init_driver() + + assert db_module._driver is None + mock_driver.close.assert_called_once() + @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_init_driver_returns_cached_driver_on_subsequent_calls( @@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout: @pytest.fixture(autouse=True) def reset_module_state(self): original_driver = db_module._driver - original_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_conn_timeout = db_module.CONNECTION_TIMEOUT db_module._driver = None yield db_module._driver = original_driver - db_module.CONN_ACQUISITION_TIMEOUT = original_timeout + db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout + db_module.CONNECTION_TIMEOUT = original_conn_timeout @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_driver_receives_configured_timeout( self, mock_driver_factory, mock_settings ): - """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver.""" + """init_driver() should pass the configured timeouts to the neo4j driver.""" mock_driver_factory.return_value = MagicMock() mock_settings.DATABASES = { "neo4j": { @@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout: } } db_module.CONN_ACQUISITION_TIMEOUT = 42 + db_module.CONNECTION_TIMEOUT = 7 db_module.init_driver() _, kwargs = mock_driver_factory.call_args assert kwargs["connection_acquisition_timeout"] == 42 + assert kwargs["connection_timeout"] == 7 class TestAtexitRegistration: From 061fbaa7bb0815163a160d3bba60780a9c0b2ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:45:06 +0200 Subject: [PATCH 017/129] feat(api): label Postgres connections with application_name per component and alias (#11494) --- api/CHANGELOG.md | 1 + api/docker-entrypoint.sh | 9 +++ .../api/tests/test_db_connection_labels.py | 55 +++++++++++++++++++ api/src/backend/config/django/base.py | 17 ++++++ api/src/backend/config/django/devel.py | 2 + api/src/backend/config/django/production.py | 2 + 6 files changed, 86 insertions(+) create mode 100644 api/src/backend/api/tests/test_db_connection_labels.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index e9dac993ce..af4b9e69bf 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file. - Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) ### 🔄 Changed diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index e6313f459a..9b2964b479 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -68,6 +68,15 @@ manage_db_partitions() { fi } +# Identify this process to Postgres (application_name=:) so +# connections are attributable by component in pg_stat_activity. Web tiers +# report "api"; everything else uses the launch subcommand. +case "$1" in + prod|dev) DJANGO_APP_COMPONENT="api" ;; + *) DJANGO_APP_COMPONENT="$1" ;; +esac +export DJANGO_APP_COMPONENT + case "$1" in dev) apply_migrations diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py new file mode 100644 index 0000000000..a39e7d9051 --- /dev/null +++ b/api/src/backend/api/tests/test_db_connection_labels.py @@ -0,0 +1,55 @@ +from config.django.base import label_postgres_connections + + +class TestLabelPostgresConnections: + def test_labels_postgres_and_skips_neo4j(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan") + databases = { + "default": {"ENGINE": "psqlextra.backend"}, + "neo4j": {"HOST": "neo4j", "PORT": "7687"}, + } + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "scan:default" + assert "OPTIONS" not in databases["neo4j"] + + def test_labels_plain_postgresql_backend(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "api") + databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}} + + label_postgres_connections(databases) + + assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas" + + def test_defaults_component_to_api_when_unset(self, monkeypatch): + monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "api:default" + + def test_preserves_existing_options(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker") + databases = { + "replica": { + "ENGINE": "psqlextra.backend", + "OPTIONS": {"sslmode": "require"}, + } + } + + label_postgres_connections(databases) + + assert databases["replica"]["OPTIONS"] == { + "sslmode": "require", + "application_name": "worker:replica", + } + + def test_truncates_application_name_to_63_bytes(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert len(databases["default"]["OPTIONS"]["application_name"]) == 63 diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 317fe4fbfb..402b71eb51 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -306,3 +306,20 @@ SESSION_COOKIE_SECURE = True ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h + + +def label_postgres_connections(databases): + """Tag each Postgres connection with ``application_name=":"`` + so connections are attributable by component in ``pg_stat_activity`` (and any + tooling that surfaces ``application_name``). The component (api / worker / + scan / ...) is injected per process by the container entrypoint via + ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the + process owns the connection. The neo4j entry is skipped (not a Postgres + backend). Postgres truncates ``application_name`` at 63 bytes. + """ + component = env.str("DJANGO_APP_COMPONENT", default="api") + for alias, config in databases.items(): + engine = config.get("ENGINE", "") + if engine.startswith("psqlextra") or "postgresql" in engine: + name = f"{component}:{alias}"[:63] + config.setdefault("OPTIONS", {})["application_name"] = name diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 9c83557b77..6921790ca3 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -54,6 +54,8 @@ DATABASES = { DATABASES["default"] = DATABASES["prowler_user"] +label_postgres_connections(DATABASES) # noqa: F405 + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405 render_class for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405 diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 91bd50d0d1..cb651f6e76 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -58,3 +58,5 @@ DATABASES = { } DATABASES["default"] = DATABASES["prowler_user"] + +label_postgres_connections(DATABASES) # noqa: F405 From 466f1a3d734b6c8d2116abd823058f745e6e8eb9 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:59:50 +0200 Subject: [PATCH 018/129] feat(okta): add user, systemlog, and idp services with DISA STIG checks (#11496) Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- .../providers/okta/authentication.mdx | 14 +- .../providers/okta/getting-started-okta.mdx | 23 +- prowler/CHANGELOG.md | 1 + .../providers/okta/lib/arguments/arguments.py | 3 +- .../providers/okta/lib/service/pagination.py | 69 +++ .../providers/okta/lib/service/raw_fetch.py | 141 ++++++ prowler/providers/okta/okta_provider.py | 8 +- .../application/application_service.py | 129 +---- .../providers/okta/services/idp/__init__.py | 0 .../providers/okta/services/idp/idp_client.py | 4 + .../okta/services/idp/idp_service.py | 118 +++++ .../__init__.py | 0 ...p_smart_card_dod_approved_ca.metadata.json | 37 ++ .../idp_smart_card_dod_approved_ca.py | 148 ++++++ .../okta/services/idp/lib/__init__.py | 0 .../okta/services/idp/lib/idp_helpers.py | 26 + .../okta/services/signon/signon_service.py | 53 +- .../okta/services/systemlog/__init__.py | 0 .../okta/services/systemlog/lib/__init__.py | 0 .../systemlog/lib/systemlog_helpers.py | 26 + .../services/systemlog/systemlog_client.py | 4 + .../services/systemlog/systemlog_service.py | 136 +++++ .../systemlog_streaming_enabled/__init__.py | 0 .../systemlog_streaming_enabled.metadata.json | 37 ++ .../systemlog_streaming_enabled.py | 88 ++++ .../providers/okta/services/user/__init__.py | 0 .../okta/services/user/lib/__init__.py | 0 .../okta/services/user/lib/user_helpers.py | 26 + .../okta/services/user/user_client.py | 4 + .../__init__.py | 0 ...ivity_automation_35d_enabled.metadata.json | 36 ++ .../user_inactivity_automation_35d_enabled.py | 204 ++++++++ .../okta/services/user/user_service.py | 455 +++++++++++++++++ .../okta/lib/service/raw_fetch_test.py | 152 ++++++ tests/providers/okta/okta_fixtures.py | 10 +- .../okta/services/idp/idp_fixtures.py | 44 ++ .../okta/services/idp/idp_service_test.py | 80 +++ .../idp_smart_card_dod_approved_ca_test.py | 125 +++++ .../services/signon/signon_service_test.py | 4 +- .../services/systemlog/systemlog_fixtures.py | 27 + .../systemlog/systemlog_service_test.py | 185 +++++++ .../systemlog_streaming_enabled_test.py | 73 +++ .../okta/services/user/user_fixtures.py | 55 ++ ..._inactivity_automation_35d_enabled_test.py | 165 ++++++ .../okta/services/user/user_service_test.py | 477 ++++++++++++++++++ 45 files changed, 3009 insertions(+), 178 deletions(-) create mode 100644 prowler/providers/okta/lib/service/pagination.py create mode 100644 prowler/providers/okta/lib/service/raw_fetch.py create mode 100644 prowler/providers/okta/services/idp/__init__.py create mode 100644 prowler/providers/okta/services/idp/idp_client.py create mode 100644 prowler/providers/okta/services/idp/idp_service.py create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py create mode 100644 prowler/providers/okta/services/idp/lib/__init__.py create mode 100644 prowler/providers/okta/services/idp/lib/idp_helpers.py create mode 100644 prowler/providers/okta/services/systemlog/__init__.py create mode 100644 prowler/providers/okta/services/systemlog/lib/__init__.py create mode 100644 prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py create mode 100644 prowler/providers/okta/services/systemlog/systemlog_client.py create mode 100644 prowler/providers/okta/services/systemlog/systemlog_service.py create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py create mode 100644 prowler/providers/okta/services/user/__init__.py create mode 100644 prowler/providers/okta/services/user/lib/__init__.py create mode 100644 prowler/providers/okta/services/user/lib/user_helpers.py create mode 100644 prowler/providers/okta/services/user/user_client.py create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py create mode 100644 prowler/providers/okta/services/user/user_service.py create mode 100644 tests/providers/okta/lib/service/raw_fetch_test.py create mode 100644 tests/providers/okta/services/idp/idp_fixtures.py create mode 100644 tests/providers/okta/services/idp/idp_service_test.py create mode 100644 tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py create mode 100644 tests/providers/okta/services/systemlog/systemlog_fixtures.py create mode 100644 tests/providers/okta/services/systemlog/systemlog_service_test.py create mode 100644 tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py create mode 100644 tests/providers/okta/services/user/user_fixtures.py create mode 100644 tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py create mode 100644 tests/providers/okta/services/user/user_service_test.py diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index c9154ee936..ca56a0535b 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -35,14 +35,18 @@ The bundled checks require the following read-only scopes: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.logStreams.read` +- `okta.idps.read` Additional scopes will be needed as more services and checks are added. These are the current ones needed: | Scope | Used by | |---|---| -| `okta.policies.read` | Sign-on, password, and authentication policies | +| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | | `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | +| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) | +| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) | ### Required Admin Role @@ -68,7 +72,9 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator. -When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. +`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement. + +When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed. @@ -158,8 +164,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" uv run python prowler-cli.py okta ``` diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 66d3dd1808..6086afefb3 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -143,10 +143,13 @@ prowler okta --config-file /path/to/config.yaml Prowler for Okta includes security checks across the following services: -| Service | Description | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | -| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| Service | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | +| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| **User** | User lifecycle automations (inactivity-based deprovisioning) | +| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM | +| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility | ## Troubleshooting @@ -158,11 +161,13 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, User, System Log, and Identity Provider services: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.logStreams.read` +- `okta.idps.read` The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. @@ -170,10 +175,10 @@ When additional checks are enabled — or when running against a service app tha ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.logStreams.read okta.idps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6922846b0d..45cf485716 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) +- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) --- diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py index 287f786b0f..4ed5cfa187 100644 --- a/prowler/providers/okta/lib/arguments/arguments.py +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -35,7 +35,8 @@ def init_parser(self): nargs="+", help=( "OAuth scopes to request, space-separated " - "(e.g. okta.policies.read okta.brands.read okta.apps.read). " + "(e.g. okta.policies.read okta.brands.read okta.apps.read " + "okta.logStreams.read okta.idps.read). " "Defaults to the read scopes required by the bundled checks." ), default=None, diff --git a/prowler/providers/okta/lib/service/pagination.py b/prowler/providers/okta/lib/service/pagination.py new file mode 100644 index 0000000000..a30bbf9477 --- /dev/null +++ b/prowler/providers/okta/lib/service/pagination.py @@ -0,0 +1,69 @@ +"""Shared pagination helpers for Okta SDK list calls. + +The Okta SDK exposes paginated list endpoints (`list_applications`, +`list_policies`, `list_log_streams`, `list_identity_providers`, …) that +return a tuple `(items, response, error)`. The next page is signalled +through an RFC 5988 `Link: <…>; rel="next"` header carrying an opaque +`after` cursor. + +These helpers are used by every Okta service that needs to drain a +paginated endpoint. They live here so we don't keep copy-pasting them +into each service module. +""" + +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +def next_after_cursor(resp) -> Optional[str]: + """Extract the `after` cursor from a `Link: ...; rel="next"` header. + + Returns None when there is no next page. Header format follows RFC + 5988 and Okta's pagination guide. + """ + if resp is None: + return None + headers = getattr(resp, "headers", None) or {} + link = headers.get("link") or headers.get("Link") or "" + if not link: + return None + for part in link.split(","): + if 'rel="next"' not in part: + continue + url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") + cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] + if cursor: + return cursor + return None + + +async def paginate(fetch): + """Drain all pages of an SDK list call. + + `fetch` is a callable that accepts the `after` cursor (or None for + the first page) and returns the SDK's standard `(items, resp, err)` + tuple — or the 2-tuple early-error shape `(items, err)`. Follows the + `Link: rel="next"` header until exhausted. The returned tuple is + `(all_items, error)` — error is non-None only when a page fails + to fetch. + """ + all_items = [] + result = await fetch(None) + err = result[-1] + if err is not None: + return [], err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + while True: + cursor = next_after_cursor(resp) + if not cursor: + break + result = await fetch(cursor) + err = result[-1] + if err is not None: + return all_items, err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + return all_items, None diff --git a/prowler/providers/okta/lib/service/raw_fetch.py b/prowler/providers/okta/lib/service/raw_fetch.py new file mode 100644 index 0000000000..42e8eede11 --- /dev/null +++ b/prowler/providers/okta/lib/service/raw_fetch.py @@ -0,0 +1,141 @@ +"""Raw-JSON HTTP fetch via the Okta SDK's request executor. + +Some Okta Management API endpoints are not yet exposed as typed methods +on the SDK client (e.g. `/api/v1/automations`), or the typed path's +pydantic deserialization rejects values the API actually returns (e.g. +the `KnowledgeConstraint.types` lowercase issue we hit on +`list_policy_rules`). In both cases we go around the typed layer: +construct the request via `client._request_executor.create_request`, +execute without a response type, and parse the body ourselves. + +`get_json` returns the parsed JSON payload (typically a list or dict) +or raises with a descriptive log line on any of the failure modes — +request build, transport, decode, parse. `get_json_paginated` drains +list endpoints by following the `Link: rel="next"` cursor — without it, +the raw fallback would silently truncate at the per-request `limit`. +Callers are expected to project the JSON onto their own pydantic snapshot. +""" + +import json +from typing import Any, Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import next_after_cursor + + +async def get_json( + client, + path: str, + *, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[Any]: + """GET `path` via the SDK's request executor and return parsed JSON. + + Returns the decoded JSON payload on success, or None when the + request, transport, or decode steps fail. Each failure path emits a + `logger.error` line tagged with `context` so the caller can grep + for it. + """ + label = context or path + request, error = await client._request_executor.create_request( + method="GET", + url=path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + _response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + try: + return json.loads(response_body) if response_body else None + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + + +async def get_json_paginated( + client, + path: str, + *, + page_size: int = 200, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[list]: + """Drain all pages of a raw-JSON list endpoint. + + Mirrors the typed `pagination.paginate` shape but operates on the + SDK's request executor directly. Follows the `Link: rel="next"` + header until exhausted, accumulating items across pages. Returns + the concatenated list, or None if any page fails to fetch or the + response is not a JSON array. + + `page_size` is appended as `limit=N` to the first request; subsequent + requests use the URL Okta returns via the cursor. + """ + label = context or path + all_items: list = [] + current_path = _set_query(path, {"limit": str(page_size)}) + while True: + request, error = await client._request_executor.create_request( + method="GET", + url=current_path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + if not response_body: + break + try: + page = json.loads(response_body) + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + if not isinstance(page, list): + logger.error( + f"Unexpected raw payload shape for {label}: " + f"{type(page).__name__}; expected list" + ) + return None + all_items.extend(page) + + cursor = next_after_cursor(response) + if not cursor: + break + current_path = _set_query(path, {"limit": str(page_size), "after": cursor}) + return all_items + + +def _set_query(path: str, params: dict) -> str: + """Return `path` with the given query params merged in (overriding existing).""" + parsed = urlparse(path) + qs = dict(parse_qsl(parsed.query)) + qs.update({k: v for k, v in params.items() if v is not None}) + return urlunparse(parsed._replace(query=urlencode(qs))) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 0a7b0b8e9d..678e9edbd4 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -32,7 +32,13 @@ from prowler.providers.okta.exceptions.exceptions import ( from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist from prowler.providers.okta.models import OktaIdentityInfo, OktaSession -DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read", "okta.apps.read"] +DEFAULT_SCOPES = [ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + "okta.logStreams.read", + "okta.idps.read", +] # Accept only Okta-managed domains. Custom (vanity) domains are rejected on # purpose — they're a recurring source of typos and silent misconfig and # Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below diff --git a/prowler/providers/okta/services/application/application_service.py b/prowler/providers/okta/services/application/application_service.py index de2fe0a658..fa6f483021 100644 --- a/prowler/providers/okta/services/application/application_service.py +++ b/prowler/providers/okta/services/application/application_service.py @@ -1,10 +1,13 @@ -import json from typing import Optional -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse from pydantic import BaseModel, ValidationError from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) from prowler.providers.okta.lib.service.service import OktaService # These three keys are Okta-platform constants, not tenant-configurable: @@ -28,29 +31,6 @@ DASHBOARD_APP_NAME = "okta_enduser" ADMIN_CONSOLE_FIRST_PARTY_APP_KEY = "admin-console" -def _next_after_cursor(resp) -> Optional[str]: - """Extract the `after` cursor from a `Link: ...; rel="next"` header. - - Returns None when there is no next page. Header format follows RFC 5988 - and Okta's pagination guide. Mirrors the helper in `signon_service` — - duplicated rather than shared until a third Okta service appears. - """ - if resp is None: - return None - headers = getattr(resp, "headers", None) or {} - link = headers.get("link") or headers.get("Link") or "" - if not link: - return None - for part in link.split(","): - if 'rel="next"' not in part: - continue - url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") - cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] - if cursor: - return cursor - return None - - REQUIRED_SCOPES: dict[str, str] = { "admin_console_app_settings": "okta.apps.read", "built_in_apps": "okta.apps.read", @@ -321,69 +301,24 @@ class Application(OktaService): """Raw-JSON fallback for `list_policy_rules`. Bypasses the Okta SDK's typed deserialization by calling the - request executor directly without a response type. The response - body is then `json.loads`-ed and projected onto our own pydantic - snapshot, which only validates the fields the STIG checks - actually read. This keeps the checks evaluable on tenants where - the Management API returns values the SDK validators reject. + request executor directly via the shared `get_json_paginated` + helper, which follows `Link: rel=next` so policies with more + rules than `rule_fetch_limit` are not silently truncated. + Projects the response onto our own pydantic snapshot which only + validates the fields the STIG checks actually read. This keeps + the checks evaluable on tenants where the Management API returns + values the SDK validators reject. """ - request, error = await self.client._request_executor.create_request( - method="GET", - url=f"/api/v1/policies/{policy_id}/rules?limit={rule_fetch_limit}", - body=None, - headers={"Accept": "application/json"}, + rules_data = await _raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=rule_fetch_limit, + context=f"access policy {policy_id} rules", ) - if error is not None: - logger.error( - f"Raw rules fetch (create_request) failed for {policy_id}: {error}" - ) + if rules_data is None: return AuthenticationPolicy( id=policy_id, name="", status="", is_default=False, rules=[] ) - - _response, response_body, error = await self.client._request_executor.execute( - request - ) - if error is not None: - logger.error(f"Raw rules fetch (execute) failed for {policy_id}: {error}") - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if isinstance(response_body, (bytes, bytearray)): - try: - response_body = response_body.decode("utf-8") - except UnicodeDecodeError as decode_err: - logger.error( - f"Could not decode rules response for {policy_id}: {decode_err}" - ) - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - try: - rules_data = json.loads(response_body) if response_body else [] - except json.JSONDecodeError as decode_err: - logger.error(f"Could not parse rules JSON for {policy_id}: {decode_err}") - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if not isinstance(rules_data, list): - logger.error( - f"Unexpected raw rules payload shape for {policy_id}: " - f"got {type(rules_data).__name__}, expected list" - ) - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if len(rules_data) >= rule_fetch_limit: - logger.warning( - f"Access policy {policy_id} returned {len(rules_data)} rules " - f"via raw-JSON fallback — the per-policy fetch limit " - f"({rule_fetch_limit}) was hit; any rules beyond this limit " - "are not evaluated by Prowler." - ) rules_out = [_raw_rule_to_model(rule) for rule in rules_data] return AuthenticationPolicy( id=policy_id, name="", status="", is_default=False, rules=rules_out @@ -391,33 +326,7 @@ class Application(OktaService): @staticmethod async def _paginate(fetch): - """Drain all pages of an SDK list call. - - `fetch` is a callable taking the `after` cursor (or None) and - returning the SDK's `(items, resp, err)` tuple. Follows the - `Link: rel="next"` header until exhausted. Mirrors the helper in - `signon_service`. - """ - all_items = [] - result = await fetch(None) - err = result[-1] - if err is not None: - return [], err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - while True: - cursor = _next_after_cursor(resp) - if not cursor: - break - result = await fetch(cursor) - err = result[-1] - if err is not None: - return all_items, err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - return all_items, None + return await _paginate_shared(fetch) def _policy_id_from_href(href: Optional[str]) -> Optional[str]: diff --git a/prowler/providers/okta/services/idp/__init__.py b/prowler/providers/okta/services/idp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_client.py b/prowler/providers/okta/services/idp/idp_client.py new file mode 100644 index 0000000000..5bbf98c17a --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.idp.idp_service import Idp + +idp_client = Idp(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/idp/idp_service.py b/prowler/providers/okta/services/idp/idp_service.py new file mode 100644 index 0000000000..2ff106cb47 --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.service import OktaService + +# Okta's API value for the "Smart Card" IdP shown in the Admin Console. +# The UI label is "Smart Card IdP" but the `type` field on the API response +# is `X509` (Mutual TLS) — that is the value we filter on. +SMART_CARD_IDP_TYPE = "X509" + +REQUIRED_SCOPES: dict[str, str] = { + "identity_providers": "okta.idps.read", +} + + +class Idp(OktaService): + """Fetches Okta Identity Providers. + + Populates `self.identity_providers` keyed by IdP id. Each entry + captures the minimum fields the bundled checks read: identity + (`id`, `name`), `type`, `status`, and — for `X509` Smart Card IdPs + — the certificate-chain `issuer` and `kid` exposed by Okta's + `protocol.credentials.trust` structure. Reading the issuer DN lets + the check surface it for out-of-band verification against the + DOD-approved CA list. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.identity_providers: dict[str, OktaIdentityProvider] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_identity_providers() + ) + + def _list_identity_providers(self) -> dict: + logger.info("Idp - Listing Okta Identity Providers...") + try: + return self._run(self._fetch_identity_providers()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_identity_providers(self) -> dict: + result: dict[str, OktaIdentityProvider] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + issuer, kid = _trust_fields(idp) + result[idp_id] = OktaIdentityProvider( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=_stringify_enum(getattr(idp, "type", None)) or "", + status=_stringify_enum(getattr(idp, "status", None)) or "", + trust_issuer=issuer, + trust_kid=kid, + ) + return result + + +def _trust_fields(idp) -> tuple[Optional[str], Optional[str]]: + """Extract `issuer` and `kid` from an `X509` IdP's protocol.credentials.trust. + + The SDK exposes `IdentityProvider.protocol` as `IdentityProviderProtocol`, + a Pydantic v2 oneOf wrapper that holds the concrete protocol (ProtocolMtls + for X509 IdPs) on `actual_instance`. `credentials` is not proxied on the + wrapper, so reading it directly returns None — we have to unwrap first. + """ + protocol = getattr(idp, "protocol", None) + if protocol is None: + return None, None + actual_protocol = getattr(protocol, "actual_instance", None) or protocol + credentials = getattr(actual_protocol, "credentials", None) + if credentials is None: + return None, None + trust = getattr(credentials, "trust", None) + if trust is None: + return None, None + return getattr(trust, "issuer", None), getattr(trust, "kid", None) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class OktaIdentityProvider(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" + trust_issuer: Optional[str] = None + trust_kid: Optional[str] = None diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json new file mode 100644 index 0000000000..e7e85294be --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "idp_smart_card_dod_approved_ca", + "CheckTitle": "Okta Smart Card (X509) Identity Provider uses a DOD-approved certificate authority", + "CheckType": [], + "ServiceName": "idp", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every Okta Smart Card (X509) Identity Provider must be `ACTIVE` and its certificate chain must be issued by a DOD-approved CA. The check ships default issuer-DN patterns covering DOD PKI and ECA, and matches them against the chain's `issuer`. Override or extend via `okta_dod_approved_ca_issuer_patterns` in the audit config to recognise tenant-specific DOD CAs.", + "Risk": "An Okta Smart Card IdP whose certificate chain is not issued by a DOD-approved CA can be used to authenticate non-vetted identities.\n\n- **Trust on an unverified CA** allows impersonation of CAC/PIV holders\n- **Bypass of the federal PKI** required for DOD-grade identity assurance\n- **Acceptance of certificates** from a private or unaccredited issuer", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/security/idp-enable-smart-card.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Identity Providers**.\n3. For each IdP whose **Type** is **Smart Card**, click **Actions** > **Configure**.\n4. Under **Certificate chain**, verify the certificate is from a DOD-approved Certificate Authority (DOD PKI, ECA, JITC, or equivalent).\n5. If the IdP is not **Active**, activate it once the chain is validated.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Verify each Okta Smart Card (X509) Identity Provider is ACTIVE and its certificate chain is issued by a DOD-approved Certificate Authority. Document the issuer for audit evidence.", + "Url": "https://hub.prowler.com/check/idp_smart_card_dod_approved_ca" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273207 / OKTA-APP-001920." +} diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py new file mode 100644 index 0000000000..5d19cca0dd --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py @@ -0,0 +1,148 @@ +import re + +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.idp.idp_client import idp_client +from prowler.providers.okta.services.idp.idp_service import ( + SMART_CARD_IDP_TYPE, + OktaIdentityProvider, +) +from prowler.providers.okta.services.idp.lib.idp_helpers import ( + missing_idps_scope_finding, +) + +# Default issuer-DN substring patterns recognised as DOD-approved Certificate +# Authorities. The DOD PKI publishes canonical DN forms that include +# `O=U.S. Government, OU=DoD` (for DoD Root, DoD ID, DoD EMAIL, DoD SW, DoD +# JITC CAs) and `O=U.S. Government, OU=ECA` for the External Certificate +# Authorities. Customers running an internal CA outside these patterns can +# extend the list via the `okta_dod_approved_ca_issuer_patterns` audit-config +# entry — see the per-check Notes in metadata.json. +DEFAULT_DOD_CA_ISSUER_PATTERNS = ( + # `OU=DoD` is the distinctive DISA DN component for every CA in the DoD + # PKI (Root, ID, EMAIL, SW, JITC). `OU=ECA` is the equivalent for the + # External Certificate Authorities. The trailing `\b` prevents accidental + # matches against superstrings like `OU=DoDExtra`. + r"\bOU=DoD\b", + r"\bOU=ECA\b", +) + + +class idp_smart_card_dod_approved_ca(Check): + """Verifies that Okta Smart Card (X509) IdPs are configured and use a DOD-approved CA. + + PASS when the IdP is `ACTIVE` and its certificate chain's `issuer` + DN matches one of the configured DOD-approved CA patterns. MANUAL + when active but the issuer doesn't match (operator can verify + out-of-band or extend the pattern list). FAIL when no Smart Card + IdP is configured or when the configured IdP is inactive. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = idp_client.provider.identity.org_domain + audit_config = idp_client.audit_config or {} + configured_patterns = audit_config.get("okta_dod_approved_ca_issuer_patterns") + patterns = ( + tuple(configured_patterns) + if configured_patterns + else DEFAULT_DOD_CA_ISSUER_PATTERNS + ) + + missing_scope = idp_client.missing_scope.get("identity_providers") + if missing_scope: + findings.append( + missing_idps_scope_finding(self.metadata(), org_domain, missing_scope) + ) + return findings + + smart_card_idps = [ + idp + for idp in idp_client.identity_providers.values() + if (idp.type or "").upper() == SMART_CARD_IDP_TYPE + ] + + if not smart_card_idps: + placeholder = OktaIdentityProvider( + id="okta-smart-card-idp-missing", + name="(no Smart Card IdP configured)", + type=SMART_CARD_IDP_TYPE, + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Smart Card (X509) Identity Providers are configured. " + "Configure a Smart Card IdP in the Admin Console " + "(Security > Identity Providers) with a certificate chain " + "issued by a DOD-approved CA. If CAC/PIV authentication is " + "not required for this tenant, mutelist this check with " + "that documented exception." + ) + findings.append(report) + return findings + + for idp in smart_card_idps: + report = CheckReportOkta( + metadata=self.metadata(), resource=idp, org_domain=org_domain + ) + label = f"Okta Smart Card IdP '{idp.name}' (id={idp.id}, type={idp.type})" + chain_detail = _format_chain_detail(idp) + + if (idp.status or "").upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"{label} is not ACTIVE (status={idp.status or 'unset'}). " + "Activate the IdP from Security > Identity Providers, then " + f"verify the certificate chain. {chain_detail}" + ) + findings.append(report) + continue + + matched_pattern = _matched_issuer_pattern(idp.trust_issuer, patterns) + if matched_pattern is not None: + report.status = "PASS" + report.status_extended = ( + f"{label} is ACTIVE and its chain issuer matches a " + f"DOD-approved CA pattern (`{matched_pattern}`). " + f"{chain_detail}" + ) + else: + report.status = "MANUAL" + report.status_extended = ( + f"{label} is ACTIVE but its chain issuer does not match any " + "configured DOD-approved CA pattern. Verify out-of-band " + "that the certificate chain belongs to a DOD-approved " + "Certificate Authority, or extend " + "`okta_dod_approved_ca_issuer_patterns` in the audit " + f"config. {chain_detail}" + ) + findings.append(report) + return findings + + +def _matched_issuer_pattern(issuer, patterns): + if not issuer: + return None + for pattern in patterns: + try: + if re.search(pattern, issuer): + return pattern + except re.error: + # Skip malformed operator-supplied patterns rather than crashing + # the whole check. + continue + return None + + +def _format_chain_detail(idp: OktaIdentityProvider) -> str: + if idp.trust_issuer or idp.trust_kid: + return ( + f"Chain issuer: {idp.trust_issuer or 'unset'}; " + f"kid: {idp.trust_kid or 'unset'}." + ) + return ( + "Chain issuer and kid were not exposed by the API; inspect the IdP in " + "the Admin Console under Security > Identity Providers > Configure." + ) diff --git a/prowler/providers/okta/services/idp/lib/__init__.py b/prowler/providers/okta/services/idp/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/lib/idp_helpers.py b/prowler/providers/okta/services/idp/lib/idp_helpers.py new file mode 100644 index 0000000000..f1689f34a1 --- /dev/null +++ b/prowler/providers/okta/services/idp/lib/idp_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA idp STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider + + +def missing_idps_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the IdPs scope is not granted.""" + placeholder = OktaIdentityProvider( + id="okta-idps-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Identity Providers: the Okta service app is " + f"missing the required `{scope}` API scope. Grant it on the service " + "app's Okta API Scopes tab in the Okta Admin Console, then re-run the " + "check." + ) + return report diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py index 7c32363501..663e9cf187 100644 --- a/prowler/providers/okta/services/signon/signon_service.py +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -1,34 +1,11 @@ from typing import Optional -from urllib.parse import parse_qs, urlparse from pydantic import BaseModel from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared from prowler.providers.okta.lib.service.service import OktaService - -def _next_after_cursor(resp) -> Optional[str]: - """Extract the `after` cursor from a `Link: ...; rel="next"` header. - - Returns None when there is no next page. Header format follows RFC 5988 - and Okta's pagination guide. - """ - if resp is None: - return None - headers = getattr(resp, "headers", None) or {} - link = headers.get("link") or headers.get("Link") or "" - if not link: - return None - for part in link.split(","): - if 'rel="next"' not in part: - continue - url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") - cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] - if cursor: - return cursor - return None - - REQUIRED_SCOPES: dict[str, str] = { "global_session_policies": "okta.policies.read", "sign_in_pages": "okta.brands.read", @@ -228,33 +205,7 @@ class Signon(OktaService): @staticmethod async def _paginate(fetch): - """Drain all pages of an SDK list call. - - `fetch` is a callable that takes the `after` cursor (or None for - the first page) and returns the SDK's standard `(items, resp, err)` - tuple. We follow `Link: rel="next"` headers until exhausted. - """ - all_items = [] - result = await fetch(None) - # Defensive against the SDK's 2-tuple early-error path: error is last. - err = result[-1] - if err is not None: - return [], err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - while True: - cursor = _next_after_cursor(resp) - if not cursor: - break - result = await fetch(cursor) - err = result[-1] - if err is not None: - return all_items, err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - return all_items, None + return await _paginate_shared(fetch) class GlobalSessionPolicyRule(BaseModel): diff --git a/prowler/providers/okta/services/systemlog/__init__.py b/prowler/providers/okta/services/systemlog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/__init__.py b/prowler/providers/okta/services/systemlog/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py new file mode 100644 index 0000000000..e82cedff5f --- /dev/null +++ b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA systemlog STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +def missing_log_streams_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the log-streams scope is not granted.""" + placeholder = LogStream( + id="okta-log-streams-scope-missing", + name="(scope not granted)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Log Streams: the Okta service app is missing " + f"the required `{scope}` API scope. Grant it on the service app's " + "Okta API Scopes tab in the Okta Admin Console, then re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/systemlog/systemlog_client.py b/prowler/providers/okta/services/systemlog/systemlog_client.py new file mode 100644 index 0000000000..3dc0a1a0af --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.systemlog.systemlog_service import SystemLog + +systemlog_client = SystemLog(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/systemlog/systemlog_service.py b/prowler/providers/okta/services/systemlog/systemlog_service.py new file mode 100644 index 0000000000..96c8b6d7ae --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_service.py @@ -0,0 +1,136 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "log_streams": "okta.logStreams.read", +} + + +class SystemLog(OktaService): + """Fetches Okta Log Stream configurations. + + Populates `self.log_streams` keyed by Log Stream id. Each entry + carries `name`, `status`, `type` — enough for the streaming-enabled + check to evaluate whether the tenant has off-loaded audit records + to an external SIEM/event bus. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding instead of a misleading + "no resources returned". + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.log_streams: dict[str, LogStream] = ( + {} if self.missing_scope["log_streams"] else self._list_log_streams() + ) + + def _list_log_streams(self) -> dict: + logger.info("SystemLog - Listing Okta Log Streams...") + try: + return self._run(self._fetch_log_streams()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_log_streams(self) -> dict: + result: dict[str, LogStream] = {} + try: + all_streams, err = await paginate( + lambda after: self.client.list_log_streams(after=after) + ) + except ValidationError as ve: + # Upstream okta-sdk-python bug: e.g. `LogStreamSettingsAws`'s + # `eventSourceName` validator regex is `^[a-zA-Z0-9.\-_]$` — + # missing the `+` quantifier, so it rejects every + # multi-character name. Fall back to raw JSON so the check + # can still evaluate the tenant's actual log-stream state. + # Remove this workaround once okta-sdk-python fixes the + # validator (issue to be filed upstream). + logger.warning( + f"Okta SDK raised ValidationError parsing log streams " + f"({ve.error_count()} error(s)) — falling back to raw-JSON " + "parse. This is an okta-sdk-python deserialization bug." + ) + return await self._fetch_log_streams_raw() + + if err is not None: + logger.error(f"Error listing log streams: {err}") + return result + + for stream in all_streams: + stream_id = getattr(stream, "id", "") or "" + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=getattr(stream, "name", "") or "", + status=getattr(stream, "status", "") or "", + type=_stringify_enum(getattr(stream, "type", None)) or "", + ) + return result + + async def _fetch_log_streams_raw(self) -> dict: + """Raw-JSON fallback for `list_log_streams`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper (which follows the `Link: rel=next` + cursor so tenants with >200 streams are not silently truncated), + and projects the response onto our own pydantic snapshot which + only validates the four fields the check reads. Keeps the check + evaluable on tenants whose Log Stream settings happen to trip + an SDK enum/regex validator. + """ + result: dict[str, LogStream] = {} + data = await raw_get_json_paginated( + self.client, + "/api/v1/logStreams", + page_size=200, + context="log streams", + ) + if data is None: + return result + for item in data: + if not isinstance(item, dict): + continue + stream_id = item.get("id") + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=item.get("name") or "", + status=(item.get("status") or "").upper(), + type=item.get("type") or "", + ) + return result + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class LogStream(BaseModel): + id: str + name: str = "" + status: str = "" + type: str = "" diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json new file mode 100644 index 0000000000..27541ee2fe --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "systemlog_streaming_enabled", + "CheckTitle": "Okta off-loads audit records to a central log server via Log Streaming", + "CheckType": [], + "ServiceName": "systemlog", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "Okta must off-load audit records to a central log server. At least one **Log Stream** (AWS EventBridge, Splunk Cloud, etc.) must be configured and `ACTIVE` in the tenant. Alternatively, an external SIEM pulling the System Log API can satisfy the requirement, but that pull-based path is verified manually.", + "Risk": "Audit records stored only inside the Okta tenant are exposed to accidental or incidental deletion or alteration.\n\n- **No central retention** of authentication events for incident investigations\n- **Single point of failure** for the audit trail\n- **No correlation** with other identity, network, and endpoint telemetry in the SIEM", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/reports/log-streaming/about-log-streams.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/LogStream/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Reports** > **Log Streaming**.\n3. Click **Add Log Stream** and select **AWS EventBridge**, **Splunk Cloud**, or another supported destination.\n4. Complete the connection fields and save.\n5. Activate the stream and verify the destination receives events.\n6. If the destination SIEM is not natively supported, document the pull-based ingestion that uses the System Log API.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure at least one ACTIVE Okta Log Stream that off-loads audit records to a central SIEM (AWS EventBridge, Splunk Cloud, or another supported destination). Document any alternative pull-based ingestion via the System Log API.", + "Url": "https://hub.prowler.com/check/systemlog_streaming_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273202 / OKTA-APP-001430." +} diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py new file mode 100644 index 0000000000..61448aeaf5 --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.systemlog.lib.systemlog_helpers import ( + missing_log_streams_scope_finding, +) +from prowler.providers.okta.services.systemlog.systemlog_client import systemlog_client +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +class systemlog_streaming_enabled(Check): + """Verifies that at least one Okta Log Stream is configured and active. + + Off-loading audit records to a central SIEM (AWS EventBridge, Splunk + Cloud, etc.) is the standard mechanism for centralised retention. + An alternative path — pulling the System Log API into an external + SIEM — is allowed by the requirement, but cannot be verified + automatically; this check emits a MANUAL note in that case. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = systemlog_client.provider.identity.org_domain + + missing_scope = systemlog_client.missing_scope.get("log_streams") + if missing_scope: + findings.append( + missing_log_streams_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + active_streams = [ + stream + for stream in systemlog_client.log_streams.values() + if not stream.status or stream.status.upper() == "ACTIVE" + ] + + if not systemlog_client.log_streams: + placeholder = LogStream( + id="okta-log-streams-missing", + name="(no Log Streams configured)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Log Streams are configured. Configure a Log Stream " + "(Reports > Log Streaming) to off-load audit records to a " + "central SIEM. If an external SIEM is already pulling logs " + "via the System Log API, mutelist this check with that " + "evidence." + ) + findings.append(report) + return findings + + if not active_streams: + placeholder = LogStream( + id="okta-log-streams-inactive", + name="(no active Log Streams)", + status="INACTIVE", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + f"{len(systemlog_client.log_streams)} Okta Log Stream(s) are " + "configured but none are ACTIVE. Activate a Log Stream to " + "off-load audit records to a central SIEM." + ) + findings.append(report) + return findings + + for stream in active_streams: + report = CheckReportOkta( + metadata=self.metadata(), resource=stream, org_domain=org_domain + ) + report.status = "PASS" + report.status_extended = ( + f"Okta Log Stream '{stream.name}' (type={stream.type or 'unset'}) " + "is ACTIVE and off-loads audit records to a central SIEM." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/user/__init__.py b/prowler/providers/okta/services/user/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/__init__.py b/prowler/providers/okta/services/user/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/user_helpers.py b/prowler/providers/okta/services/user/lib/user_helpers.py new file mode 100644 index 0000000000..423801f2d8 --- /dev/null +++ b/prowler/providers/okta/services/user/lib/user_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA user STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.user.user_service import UserAutomation + + +def missing_user_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when an OAuth scope is not granted.""" + placeholder = UserAutomation( + id="okta-user-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta user lifecycle automations: the Okta service " + f"app is missing the required `{scope}` API scope. Grant it on the " + "service app's Okta API Scopes tab in the Okta Admin Console, then " + "re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/user/user_client.py b/prowler/providers/okta/services/user/user_client.py new file mode 100644 index 0000000000..9a49fbdbac --- /dev/null +++ b/prowler/providers/okta/services/user/user_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.user.user_service import User + +user_client = User(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json new file mode 100644 index 0000000000..64f24d95ae --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "okta", + "CheckID": "user_inactivity_automation_35d_enabled", + "CheckTitle": "Okta automation suspends or deactivates users after 35 days of inactivity", + "CheckType": [], + "ServiceName": "user", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An Okta **Workflows Automation** must disable inactive user accounts. The automation must be `ACTIVE`, on an `ACTIVE` schedule, evaluate `User Inactivity = 35 days` (or less), apply to a group covering every user, and trigger `Suspended` / `Deactivated` / `Deprovisioned`. Threshold override: `okta_user_inactivity_max_days`. N/A when user sourcing is delegated to Active Directory or LDAP.", + "Risk": "Inactive Okta accounts retained indefinitely give an attacker who exploits one undetected access to downstream applications.\n\n- **Account takeover via dormant identities** that no one is monitoring\n- **Lateral movement** through SSO sessions of forgotten users\n- **Stale entitlements** that survive role and policy reorganisations", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/automation-hooks/automations-main.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Workflow** > **Automations** and click **Add Automation**.\n3. Name the automation (e.g., `User Inactivity`).\n4. Add a condition: select **User Inactivity in Okta** and enter `35` days.\n5. Configure the schedule to run daily and activate it.\n6. Apply the automation to a group that covers every user — typically `Everyone`.\n7. Add an action: **Change User lifecycle state in Okta** and choose `Suspended` (or `Deactivated`/`Deprovisioned`).\n8. Activate the automation.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create an active Okta Workflows automation that runs daily, evaluates `User Inactivity in Okta = 35 days`, applies to a group covering every user, and changes the user lifecycle state to Suspended/Deactivated. If user sourcing is delegated to Active Directory or LDAP, document that the connected directory enforces this requirement instead.", + "Url": "https://hub.prowler.com/check/user_inactivity_automation_35d_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273188 / OKTA-APP-000090." +} diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py new file mode 100644 index 0000000000..79e77c3f73 --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py @@ -0,0 +1,204 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.user.lib.user_helpers import ( + missing_user_scope_finding, +) +from prowler.providers.okta.services.user.user_client import user_client +from prowler.providers.okta.services.user.user_service import UserAutomation + +DEFAULT_INACTIVITY_DAYS = 35 +SUSPENSION_LIFECYCLE_ACTIONS = {"SUSPENDED", "DEACTIVATED", "DEPROVISIONED"} + + +class user_inactivity_automation_35d_enabled(Check): + """Verifies that Okta suspends/deactivates users after 35 days of inactivity. + + A Workflows Automation must exist with: + - status ACTIVE, + - schedule active, + - condition `User Inactivity in Okta = 35 days`, + - action that changes the user state to Suspended / Deactivated, + - applied to a group covering every user (typically `Everyone`). + + When user sourcing is delegated to an external directory (Active + Directory or LDAP), the requirement is N/A on the Okta side — the + connected directory is expected to enforce inactivity-based + deactivation instead. Threshold override: + `okta_user_inactivity_max_days` in the audit config. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + audit_config = user_client.audit_config or {} + threshold_days = audit_config.get( + "okta_user_inactivity_max_days", DEFAULT_INACTIVITY_DAYS + ) + org_domain = user_client.provider.identity.org_domain + + for scope_key in ("automations", "identity_providers"): + missing_scope = user_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_user_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + # External-directory N/A path. + if user_client.external_directory_idps: + idp_names = ", ".join( + f"'{idp.name}' (type={idp.type})" + for idp in user_client.external_directory_idps.values() + ) + placeholder = UserAutomation( + id="okta-user-inactivity-na-external-directory", + name="(external directory enforces inactivity)", + status="N/A", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "MANUAL" + report.status_extended = ( + "User sourcing is delegated to an external directory " + f"({idp_names}). The 35-day inactivity disable requirement is " + "expected to be enforced by the connected directory rather " + "than by an Okta automation. Confirm out-of-band that the " + "external directory disables accounts after " + f"{threshold_days} days of inactivity." + ) + findings.append(report) + return findings + + compliant_automations = [ + automation + for automation in user_client.automations.values() + if _is_compliant(automation, threshold_days) + ] + + if not user_client.automations: + placeholder = UserAutomation( + id="okta-user-inactivity-no-automations", + name="(no automations configured)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Workflows automations are configured. Create an " + "automation that suspends or deactivates users after " + f"{threshold_days} days of inactivity, scoped to a group " + "covering every user (typically 'Everyone'), with an active " + "schedule." + ) + findings.append(report) + return findings + + if compliant_automations: + for automation in compliant_automations: + report = CheckReportOkta( + metadata=self.metadata(), + resource=automation, + org_domain=org_domain, + ) + report.status = "PASS" + groups_label = ", ".join(automation.applies_to_groups) + report.status_extended = ( + f"Okta automation '{automation.name}' is ACTIVE with an " + f"active schedule, triggers after " + f"{automation.inactivity_days} days of inactivity, and " + f"changes the user state to " + f"{automation.lifecycle_action or 'unset'}. " + f"Applied to group(s): {groups_label}. Verify that these " + "group(s) cover every user. Okta has no built-in " + "'Everyone' group ID, so tenant-wide coverage cannot be " + "asserted automatically." + ) + findings.append(report) + return findings + + # Automations exist but none satisfy the predicate — surface the + # closest candidate for the auditor. + candidate = _closest_candidate(user_client.automations.values()) + report = CheckReportOkta( + metadata=self.metadata(), + resource=candidate + or UserAutomation( + id="okta-user-inactivity-noncompliant", + name="(no compliant automation)", + status="MISSING", + ), + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = _failure_message(candidate, threshold_days) + findings.append(report) + return findings + + +def _is_compliant(automation: UserAutomation, threshold_days: int) -> bool: + # `applies_to_groups` must be non-empty — Okta USER_LIFECYCLE policies + # do not implicitly cover every user; the scope is whatever group IDs + # the operator put in `people.groups.include`. An empty scope means + # the automation runs against nobody. Operator must still verify those + # group(s) cover the intended user population (surfaced in the PASS + # status_extended). + return bool( + automation.status.upper() == "ACTIVE" + and automation.schedule_status.upper() == "ACTIVE" + and automation.inactivity_days is not None + and automation.inactivity_days <= threshold_days + and (automation.lifecycle_action or "").upper() in SUSPENSION_LIFECYCLE_ACTIONS + and bool(automation.applies_to_groups) + ) + + +def _closest_candidate(automations): + automations = list(automations) + if not automations: + return None + automations.sort( + key=lambda a: ( + 0 if a.status.upper() == "ACTIVE" else 1, + 0 if a.schedule_status.upper() == "ACTIVE" else 1, + ( + abs(a.inactivity_days - DEFAULT_INACTIVITY_DAYS) + if a.inactivity_days is not None + else 10_000 + ), + a.name, + ) + ) + return automations[0] + + +def _failure_message(automation, threshold_days): + if automation is None: + return f"No Okta automation enforces {threshold_days}-day inactivity disable." + issues = [] + if automation.status.upper() != "ACTIVE": + issues.append(f"status {automation.status or 'unset'}") + if automation.schedule_status.upper() != "ACTIVE": + issues.append(f"schedule {automation.schedule_status or 'unset'}") + if automation.inactivity_days is None: + issues.append("no inactivity condition") + elif automation.inactivity_days > threshold_days: + issues.append( + f"inactivity {automation.inactivity_days}d (max {threshold_days}d)" + ) + action = (automation.lifecycle_action or "").upper() + if action not in SUSPENSION_LIFECYCLE_ACTIONS: + issues.append(f"action {automation.lifecycle_action or 'unset'}") + if not automation.applies_to_groups: + issues.append("no group scope") + detail = ", ".join(issues) if issues else "incomplete" + return ( + f"Okta automation '{automation.name}' fails {threshold_days}d " + f"inactivity: {detail}." + ) diff --git a/prowler/providers/okta/services/user/user_service.py b/prowler/providers/okta/services/user/user_service.py new file mode 100644 index 0000000000..c816bd38ee --- /dev/null +++ b/prowler/providers/okta/services/user/user_service.py @@ -0,0 +1,455 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +# External-directory IdP `type` values that delegate user sourcing to a +# separate identity store. When any of these is present and ACTIVE, the +# STIG's 35-day inactivity disable requirement is N/A on the Okta side — +# the connected directory is expected to enforce it instead. +EXTERNAL_DIRECTORY_IDP_TYPES = {"ACTIVE_DIRECTORY", "LDAP"} + +# Okta exposes "Workflow > Automations" as USER_LIFECYCLE policies with +# inactivity rule conditions, not as a standalone `/api/v1/automations` +# resource. The SDK's `UserPolicyRuleCondition.inactivity` and +# `ScheduledUserLifecycleAction` models confirm this; the API rejects +# every other `type` candidate. +USER_LIFECYCLE_POLICY_TYPE = "USER_LIFECYCLE" + +REQUIRED_SCOPES: dict[str, str] = { + "automations": "okta.policies.read", + "identity_providers": "okta.idps.read", +} + + +class User(OktaService): + """Fetches Okta User Lifecycle Automations and external-directory IdPs. + + Populates: + - `self.automations` — keyed by USER_LIFECYCLE policy rule id. Each + entry projects the fields the 35-day inactivity check evaluates: + identity (`id`, `name` — taken from the rule), `status`, + `schedule_status` (inherited from the parent policy), the + `inactivity_days` condition and `applies_to_groups` scope from the + parent policy, and the `lifecycle_action` from the rule. + - `self.external_directory_idps` — keyed by IdP id. Used to short + circuit the STIG to N/A when user sourcing is delegated to an + external directory (Active Directory, LDAP). + + The Okta Admin Console's "Workflow > Automations" page is rendered + on top of `USER_LIFECYCLE` policies in the Management API + (`list_policies(type='USER_LIFECYCLE')` + `list_policy_rules(...)`). + There is no standalone `/api/v1/automations` GET endpoint; the SDK's + `InactivityPolicyRuleCondition`, `UserPolicyRuleCondition`, and + `ScheduledUserLifecycleAction` models all hang off the policy API. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.automations: dict[str, UserAutomation] = ( + {} if self.missing_scope["automations"] else self._list_automations() + ) + self.external_directory_idps: dict[str, ExternalDirectoryIdp] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_external_directory_idps() + ) + + def _list_automations(self) -> dict: + logger.info("User - Listing USER_LIFECYCLE policies and rules...") + try: + return self._run(self._fetch_automations()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_automations(self) -> dict: + result: dict[str, UserAutomation] = {} + try: + all_policies, err = await paginate( + lambda after: self.client.list_policies( + type=USER_LIFECYCLE_POLICY_TYPE, after=after + ) + ) + except (ValueError, ValidationError) as ex: + # Upstream okta-sdk-python bug: `Policy.from_dict` uses a + # discriminator dispatch that maps `type` → concrete Policy + # subclass, and `USER_LIFECYCLE` is not in the map. The SDK + # raises ValueError ("failed to lookup discriminator value") + # even though the API returns a valid policy. Fall back to + # raw JSON. Remove once okta-sdk-python adds + # USER_LIFECYCLE → UserLifecyclePolicy to the mapping. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing USER_LIFECYCLE " + "policies — falling back to raw-JSON parse. This is an " + "okta-sdk-python deserialization bug " + "(missing discriminator mapping)." + ) + return await self._fetch_automations_raw() + + if err is not None: + logger.error(f"Error listing USER_LIFECYCLE policies: {err}") + return result + + for policy in all_policies: + policy_id = getattr(policy, "id", "") or "" + if not policy_id: + continue + policy_status = _stringify_enum(getattr(policy, "status", None)) or "" + policy_name = getattr(policy, "name", "") or "" + rules = await self._fetch_rules(policy_id) + if rules is None: + # Rule typed parsing tripped an SDK validator. Re-run the + # whole automation discovery via raw JSON so we don't lose + # the rule data for this — or any other — policy. Cheaper + # than mixing typed and raw projections. + logger.warning( + f"Rule typed parsing failed for USER_LIFECYCLE policy " + f"{policy_id} — re-running all automations via raw-JSON." + ) + return await self._fetch_automations_raw() + if not rules: + # A policy with no rules exists in the Admin Console UI as + # an "Automation" the operator hasn't finished configuring + # (no conditions, no actions). Emit a placeholder so the + # check FAILs with a specific message naming every missing + # piece, instead of pretending the policy doesn't exist. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule in rules: + automation = _rule_to_automation(rule, policy) + if automation is None: + continue + result[automation.id] = automation + return result + + async def _fetch_rules(self, policy_id: str) -> Optional[list]: + """Return the policy's typed rules, or None to signal raw fallback. + + The Okta SDK's `list_policy_rules` shares the same brittle typed + deserialization as `list_policies` (strict pydantic validators + rejecting values the API actually returns). When that happens the + caller can't reuse any of the typed projection for this policy — + we return None as a sentinel and the caller re-runs the whole + discovery via `_fetch_automations_raw`. Returning `[]` would + otherwise misclassify the policy as an "unfinished automation" + and FAIL it. + """ + rule_fetch_limit = 100 + try: + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing rules for " + f"USER_LIFECYCLE policy {policy_id} — signaling raw fallback." + ) + return None + err = result[-1] + if err is not None: + logger.error( + f"Error listing rules for USER_LIFECYCLE policy {policy_id}: {err}" + ) + return [] + rules = list(result[0] or []) + if len(rules) >= rule_fetch_limit: + logger.warning( + f"USER_LIFECYCLE policy {policy_id} returned {len(rules)} rules — " + f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated." + ) + return rules + + async def _fetch_automations_raw(self) -> dict: + """Raw-JSON fallback for `list_policies(type='USER_LIFECYCLE')`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then drains each policy's rules + via the same path. Projects everything onto our `UserAutomation` + snapshot which only validates the fields the check reads. + """ + result: dict[str, UserAutomation] = {} + policies_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies?type={USER_LIFECYCLE_POLICY_TYPE}", + page_size=200, + context="USER_LIFECYCLE policies", + ) + if policies_data is None: + return result + + for policy_dict in policies_data: + if not isinstance(policy_dict, dict): + continue + policy_id = policy_dict.get("id") + if not policy_id: + continue + policy_status = (policy_dict.get("status") or "").upper() + policy_name = policy_dict.get("name") or "" + + rules_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=100, + context=f"USER_LIFECYCLE policy {policy_id} rules", + ) + if not rules_data: + # No rules under the policy → emit placeholder. Same + # rationale as the typed path: surface the unfinished + # automation so the check can name what's missing. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule_dict in rules_data: + automation = _raw_rule_to_automation( + rule_dict, policy_dict, policy_id, policy_name, policy_status + ) + if automation is None: + continue + result[automation.id] = automation + return result + + def _list_external_directory_idps(self) -> dict: + logger.info("User - Listing Okta IdPs for external-directory detection...") + try: + return self._run(self._fetch_external_directory_idps()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_external_directory_idps(self) -> dict: + result: dict[str, ExternalDirectoryIdp] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_type = _stringify_enum(getattr(idp, "type", None)) or "" + if idp_type.upper() not in EXTERNAL_DIRECTORY_IDP_TYPES: + continue + idp_status = _stringify_enum(getattr(idp, "status", None)) or "" + if idp_status.upper() != "ACTIVE": + continue + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + result[idp_id] = ExternalDirectoryIdp( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=idp_type, + status=idp_status, + ) + return result + + +def _rule_to_automation(rule, policy) -> Optional["UserAutomation"]: + """Project a typed USER_LIFECYCLE policy + rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* (`rule.actions.user_lifecycle.action` + on the typed model; `updateUserLifecycle.targetStatus` on raw JSON). + The rule's own `conditions` is typically empty. Projecting requires + both — kept aligned with `_raw_rule_to_automation` so the two paths + yield identical snapshots. + """ + rule_id = getattr(rule, "id", "") or "" + if not rule_id: + return None + + policy_id = getattr(policy, "id", "") or "" + policy_name = getattr(policy, "name", "") or "" + policy_status = (_stringify_enum(getattr(policy, "status", None)) or "").upper() + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + conditions = getattr(policy, "conditions", None) + people = getattr(conditions, "people", None) if conditions else None + users = getattr(people, "users", None) if people else None + inactivity = getattr(users, "inactivity", None) if users else None + if inactivity is not None: + number = getattr(inactivity, "number", None) + unit = (_stringify_enum(getattr(inactivity, "unit", None)) or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = getattr(people, "groups", None) if people else None + include_groups = getattr(groups, "include", None) if groups else None + if include_groups: + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE. + actions = getattr(rule, "actions", None) + user_lifecycle = ( + getattr(actions, "user_lifecycle", None) if actions else None + ) or (getattr(actions, "userLifecycle", None) if actions else None) + lifecycle_action: Optional[str] = None + if user_lifecycle is not None: + for attr in ("action", "status"): + value = _stringify_enum(getattr(user_lifecycle, attr, None)) + if value: + lifecycle_action = value.upper() + break + + rule_name = getattr(rule, "name", "") or policy_name or "(unnamed)" + rule_status = _stringify_enum(getattr(rule, "status", None)) or "" + + return UserAutomation( + id=rule_id, + name=rule_name, + status=rule_status.upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _raw_rule_to_automation( + rule_dict, + policy_dict, + policy_id: str, + policy_name: str, + policy_status: str, +) -> Optional["UserAutomation"]: + """Project a raw USER_LIFECYCLE policy+rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* + (`rule.actions.updateUserLifecycle.targetStatus`). The rule's own + `conditions` is typically empty `{}`. Projecting requires both. + + Schedule isn't exposed by the API on either resource. Okta runs an + automation on its UI-configured schedule iff the policy is ACTIVE, + so we treat `policy.status` as the schedule proxy. + """ + if not isinstance(rule_dict, dict): + return None + rule_id = rule_dict.get("id") + if not rule_id: + return None + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + if isinstance(policy_dict, dict): + policy_conditions = policy_dict.get("conditions") or {} + people = policy_conditions.get("people") or {} + users = people.get("users") or {} + inactivity = users.get("inactivity") + if isinstance(inactivity, dict): + number = inactivity.get("number") + unit = (inactivity.get("unit") or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = people.get("groups") or {} + include_groups = groups.get("include") + if isinstance(include_groups, list): + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus` (the API uses + # "updateUserLifecycle" rather than the SDK's `user_lifecycle`). + rule_actions = rule_dict.get("actions") or {} + update_user_lifecycle = rule_actions.get("updateUserLifecycle") or {} + lifecycle_action: Optional[str] = None + if isinstance(update_user_lifecycle, dict): + target = update_user_lifecycle.get("targetStatus") + if isinstance(target, str) and target: + lifecycle_action = target.upper() + + return UserAutomation( + id=rule_id, + name=(rule_dict.get("name") or policy_name or "(unnamed)"), + status=(rule_dict.get("status") or "").upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _shell_automation( + policy_id: str, policy_name: str, policy_status: str +) -> "UserAutomation": + """Placeholder UserAutomation for a USER_LIFECYCLE policy with no rules. + + Surfaces the unfinished automation in `self.automations` so the check + can list every missing piece in its FAIL message (no inactivity + condition, no lifecycle action, status inactive, etc.) instead of + silently dropping the policy. + """ + upper_status = (policy_status or "").upper() + return UserAutomation( + id=policy_id, + name=policy_name or "(unnamed automation)", + status=upper_status, + schedule_status=upper_status, + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class UserAutomation(BaseModel): + id: str + name: str = "" + status: str = "" + schedule_status: str = "" + inactivity_days: Optional[int] = None + lifecycle_action: Optional[str] = None + applies_to_groups: list[str] = [] + policy_id: str = "" + policy_name: str = "" + + +class ExternalDirectoryIdp(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" diff --git a/tests/providers/okta/lib/service/raw_fetch_test.py b/tests/providers/okta/lib/service/raw_fetch_test.py new file mode 100644 index 0000000000..173e78888a --- /dev/null +++ b/tests/providers/okta/lib/service/raw_fetch_test.py @@ -0,0 +1,152 @@ +"""Tests for the raw-JSON HTTP helpers in +`prowler.providers.okta.lib.service.raw_fetch`. + +Covers `get_json` (single-shot) and `get_json_paginated` +(drains list endpoints via the `Link: rel="next"` cursor). +""" + +import asyncio +import json +from unittest import mock + +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json, + get_json_paginated, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _mock_response(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +class Test_get_json: + def test_returns_parsed_json_on_success(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), json.dumps({"hello": "world"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json(client, "/x")) == {"hello": "world"} + + def test_returns_none_on_create_request_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return (None, Exception("boom")) + + client._request_executor.create_request = create + assert _run(get_json(client, "/x")) is None + + def test_returns_none_on_execute_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), None, Exception("boom")) + + client._request_executor.create_request = create + client._request_executor.execute = execute + assert _run(get_json(client, "/x")) is None + + +class Test_get_json_paginated: + def test_drains_all_pages_following_link_rel_next(self): + # Two pages: first carries `Link: <…?after=cur1>; rel="next"`, + # second has no `next`, so iteration stops. + client = mock.MagicMock() + + page1 = [{"id": "a"}, {"id": "b"}] + page2 = [{"id": "c"}] + page1_headers = { + "link": '; rel="next"' + } + + seen_urls = [] + + async def create(**kwargs): + seen_urls.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(request): + if "after=cur1" in request["url"]: + return (_mock_response({}), json.dumps(page2), None) + return (_mock_response(page1_headers), json.dumps(page1), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + items = _run(get_json_paginated(client, "/api/v1/items", page_size=2)) + + assert items == [{"id": "a"}, {"id": "b"}, {"id": "c"}] + assert len(seen_urls) == 2 + assert "limit=2" in seen_urls[0] + # The cursor was carried into the second request. + assert "after=cur1" in seen_urls[1] + assert "limit=2" in seen_urls[1] + + def test_single_page_terminates_immediately(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps([{"id": "only"}]), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) == [{"id": "only"}] + + def test_returns_none_when_response_is_not_a_list(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps({"error": "nope"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) is None + + def test_preserves_existing_query_string_and_overrides_limit(self): + # Caller already passes `type=USER_LIFECYCLE` — pagination must + # merge `limit` without clobbering existing params. + client = mock.MagicMock() + seen = [] + + async def create(**kwargs): + seen.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), "[]", None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + _run( + get_json_paginated( + client, "/api/v1/policies?type=USER_LIFECYCLE", page_size=50 + ) + ) + + assert "type=USER_LIFECYCLE" in seen[0] + assert "limit=50" in seen[0] diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 23d770cf88..83c8812495 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -16,7 +16,13 @@ def set_mocked_okta_provider( session = OktaSession( org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID, - scopes=["okta.policies.read", "okta.brands.read", "okta.apps.read"], + scopes=[ + "okta.policies.read", + "okta.brands.read", + "okta.apps.read", + "okta.logStreams.read", + "okta.idps.read", + ], private_key=OKTA_PRIVATE_KEY, ) if identity is None: @@ -27,6 +33,8 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.logStreams.read", + "okta.idps.read", ], ) diff --git a/tests/providers/okta/services/idp/idp_fixtures.py b/tests/providers/okta/services/idp/idp_fixtures.py new file mode 100644 index 0000000000..8ebc449717 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_fixtures.py @@ -0,0 +1,44 @@ +"""Shared helpers for `idp` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_idp_client( + identity_providers: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.identity_providers = identity_providers or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"identity_providers": None} + return client + + +def smart_card_idp( + idp_id: str = "0oa-x509", + name: str = "CAC IdP", + status: str = "ACTIVE", + issuer: str = "CN=DOD ROOT CA 6", + kid: str = "kid-abc-123", +): + return OktaIdentityProvider( + id=idp_id, + name=name, + type="X509", + status=status, + trust_issuer=issuer, + trust_kid=kid, + ) + + +def non_smart_card_idp( + idp_id: str = "0oa-saml", + name: str = "Corporate SAML", + type: str = "SAML2", + status: str = "ACTIVE", +): + return OktaIdentityProvider(id=idp_id, name=name, type=type, status=status) diff --git a/tests/providers/okta/services/idp/idp_service_test.py b/tests/providers/okta/services/idp/idp_service_test.py new file mode 100644 index 0000000000..3c30c0d3eb --- /dev/null +++ b/tests/providers/okta/services/idp/idp_service_test.py @@ -0,0 +1,80 @@ +import json +from unittest import mock + +from okta.models.identity_provider_protocol import IdentityProviderProtocol + +from prowler.providers.okta.services.idp.idp_service import Idp, OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_idp(idp_id, name, type_, status="ACTIVE", issuer=None, kid=None): + # Build a real `IdentityProviderProtocol` when issuer/kid are provided + # so the test exercises the SDK's Pydantic v2 oneOf wrapper — credentials + # live on `actual_instance`, not directly on the wrapper. MagicMock + # auto-attribute-creation would otherwise hide a missed unwrap. + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = type_ + idp.status = status + if issuer is None and kid is None: + idp.protocol = None + else: + idp.protocol = IdentityProviderProtocol.from_json( + json.dumps( + { + "type": "MTLS", + "credentials": {"trust": {"issuer": issuer, "kid": kid}}, + } + ) + ) + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_Idp_service: + def test_fetches_idps_with_trust_fields(self): + provider = set_mocked_okta_provider() + x509 = _fake_idp( + "0oa1", + "CAC", + "X509", + issuer="CN=DOD ROOT CA 6", + kid="kid-1", + ) + saml = _fake_idp("0oa2", "Corp", "SAML2") + + async def fake_list(*_a, **_k): + return ([x509, saml], _resp({}), None) + + with _patch_sdk(list_identity_providers=fake_list): + service = Idp(provider) + + assert set(service.identity_providers.keys()) == {"0oa1", "0oa2"} + assert isinstance(service.identity_providers["0oa1"], OktaIdentityProvider) + assert service.identity_providers["0oa1"].trust_issuer == "CN=DOD ROOT CA 6" + assert service.identity_providers["0oa1"].trust_kid == "kid-1" + assert service.identity_providers["0oa2"].trust_issuer is None + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("API failure")) + + with _patch_sdk(list_identity_providers=failing): + service = Idp(provider) + + assert service.identity_providers == {} diff --git a/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py new file mode 100644 index 0000000000..f6517e14a9 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py @@ -0,0 +1,125 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.idp.idp_fixtures import ( + build_idp_client, + non_smart_card_idp, + smart_card_idp, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.idp." + "idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca.idp_client" +) + +DOD_PKI_ISSUER = "CN=DoD ID CA-59, OU=PKI, OU=DoD, O=U.S. Government, C=US" +ECA_ISSUER = "CN=ECA Root CA 4, OU=ECA, O=U.S. Government, C=US" +NON_DOD_ISSUER = "CN=ACME Internal Root, O=Acme Corp, C=US" + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.idp.idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca import ( + idp_smart_card_dod_approved_ca, + ) + + return idp_smart_card_dod_approved_ca().execute() + + +class Test_idp_smart_card_dod_approved_ca: + def test_pass_when_active_idp_chain_matches_dod_pki_pattern(self): + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "OU=DoD" in findings[0].status_extended + assert DOD_PKI_ISSUER in findings[0].status_extended + + def test_pass_when_active_idp_chain_matches_eca_pattern(self): + idp = smart_card_idp(name="ECA Partner", issuer=ECA_ISSUER, kid="kid-e") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "OU=ECA" in findings[0].status_extended + + def test_manual_when_active_but_issuer_does_not_match_any_pattern(self): + idp = smart_card_idp(name="Custom", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert NON_DOD_ISSUER in findings[0].status_extended + assert "okta_dod_approved_ca_issuer_patterns" in findings[0].status_extended + + def test_pass_when_audit_config_pattern_matches(self): + idp = smart_card_idp(name="Custom DOD", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=ACME Internal Root"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_audit_config_overrides_bundled_defaults(self): + # When the operator supplies a list, the bundled DEFAULT patterns + # are replaced (not merged) so customers can carve out a strict set. + idp = smart_card_idp(name="DoD", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=YourTenantCustomDodCA"] + } + findings = _run_check(client) + assert findings[0].status == "MANUAL" + + def test_malformed_audit_config_pattern_skipped(self): + # An invalid regex from the operator must not crash the whole check. + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"[invalid(regex", r"OU=DoD"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_x509_idp_is_inactive(self): + idp = smart_card_idp(status="INACTIVE", issuer=DOD_PKI_ISSUER) + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_fail_when_no_smart_card_idp_configured(self): + client = build_idp_client(identity_providers={"saml": non_smart_card_idp()}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert ( + "No Smart Card (X509) Identity Providers are configured" + in findings[0].status_extended + ) + assert "mutelist" in findings[0].status_extended + + def test_manual_when_idps_scope_missing(self): + client = build_idp_client( + missing_scope={"identity_providers": "okta.idps.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.idps.read" in findings[0].status_extended + + def test_multiple_x509_idps_yield_one_finding_each(self): + idp_a = smart_card_idp(idp_id="0oa-a", name="A", issuer=DOD_PKI_ISSUER) + idp_b = smart_card_idp( + idp_id="0oa-b", name="B", status="INACTIVE", issuer=DOD_PKI_ISSUER + ) + client = build_idp_client(identity_providers={idp_a.id: idp_a, idp_b.id: idp_b}) + findings = _run_check(client) + assert len(findings) == 2 + # We don't strictly assert ordering — just that both are covered. + statuses = sorted(f.status for f in findings) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py index c408bc1221..0caeb0e680 100644 --- a/tests/providers/okta/services/signon/signon_service_test.py +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -1,12 +1,14 @@ from unittest import mock +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor as _next_after_cursor, +) from prowler.providers.okta.models import OktaIdentityInfo from prowler.providers.okta.services.signon.signon_service import ( GlobalSessionPolicy, GlobalSessionPolicyRule, SignInPage, Signon, - _next_after_cursor, ) from tests.providers.okta.okta_fixtures import ( OKTA_CLIENT_ID, diff --git a/tests/providers/okta/services/systemlog/systemlog_fixtures.py b/tests/providers/okta/services/systemlog/systemlog_fixtures.py new file mode 100644 index 0000000000..efc8289f43 --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_fixtures.py @@ -0,0 +1,27 @@ +"""Shared helpers for `systemlog` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_systemlog_client( + log_streams: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.log_streams = log_streams or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"log_streams": None} + return client + + +def log_stream( + stream_id: str = "log-1", + name: str = "EventBridge stream", + status: str = "ACTIVE", + type: str = "AWS_EVENTBRIDGE", +): + return LogStream(id=stream_id, name=name, status=status, type=type) diff --git a/tests/providers/okta/services/systemlog/systemlog_service_test.py b/tests/providers/okta/services/systemlog/systemlog_service_test.py new file mode 100644 index 0000000000..63dee95dcc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_service_test.py @@ -0,0 +1,185 @@ +import json +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.systemlog.systemlog_service import ( + LogStream, + SystemLog, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_stream( + stream_id: str, name: str, status: str = "ACTIVE", type_: str = "AWS_EVENTBRIDGE" +): + s = mock.MagicMock() + s.id = stream_id + s.name = name + s.status = status + s.type = type_ + return s + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_SystemLog_service: + def test_fetches_active_streams(self): + provider = set_mocked_okta_provider() + s1 = _fake_stream("log-1", "EventBridge") + s2 = _fake_stream("log-2", "Splunk", type_="SPLUNK_CLOUD_LOGSTREAMING") + + async def fake_list(*_a, **_k): + return ([s1, s2], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert isinstance(service.log_streams["log-1"], LogStream) + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + with _patch_sdk(list_log_streams=failing): + service = SystemLog(provider) + + assert service.log_streams == {} + + def test_skips_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], # no logStreams scope + ) + provider = set_mocked_okta_provider(identity=identity) + + called = False + + async def fake_list(*_a, **_k): + nonlocal called + called = True + return ([], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert called is False + assert service.log_streams == {} + assert service.missing_scope["log_streams"] == "okta.logStreams.read" + + +class Test_SystemLog_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback when the Okta SDK rejects API values. + + The SDK's `LogStreamSettingsAws.eventSourceName` validator uses the + regex `^[a-zA-Z0-9.\\-_]$` — missing the `+` quantifier, so every + multi-character name raises pydantic `ValidationError`. Without the + fallback the whole stream list is lost; with it, the raw JSON path + still surfaces each stream's id/name/status/type. + """ + + def test_raw_fallback_projects_streams_when_sdk_raises(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + raw_payload = [ + { + "id": "log-1", + "name": "EventBridge prod", + "status": "ACTIVE", + "type": "AWS_EVENTBRIDGE", + }, + { + "id": "log-2", + "name": "Splunk staging", + "status": "INACTIVE", + "type": "SPLUNK_CLOUD_LOGSTREAMING", + }, + ] + + async def failing_list_log_streams(*_a, **_k): + try: + # Trigger a real pydantic ValidationError so we exercise + # the exact exception type the SDK raises in production. + from okta.models.log_stream_settings_aws import LogStreamSettingsAws + + LogStreamSettingsAws( + accountId="123456789012", + eventSourceName="MultiCharacter", + region="us-east-1", + ) + except ValidationError as ve: + raise ve + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_raw_execute(_request): + return (None, json.dumps(raw_payload), None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing_list_log_streams + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert service.log_streams["log-1"].status == "ACTIVE" + assert service.log_streams["log-2"].status == "INACTIVE" + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_raw_fallback_handles_empty_list(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + raise ValidationError.from_exception_data( + title="LogStreamSettingsAws", + line_errors=[], + ) + + async def fake_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_execute(_req): + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing + sdk._request_executor.create_request = fake_create + sdk._request_executor.execute = fake_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert service.log_streams == {} diff --git a/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py new file mode 100644 index 0000000000..64d2bcb8fc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py @@ -0,0 +1,73 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.systemlog.systemlog_fixtures import ( + build_systemlog_client, + log_stream, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.systemlog." + "systemlog_streaming_enabled.systemlog_streaming_enabled.systemlog_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.systemlog.systemlog_streaming_enabled.systemlog_streaming_enabled import ( + systemlog_streaming_enabled, + ) + + return systemlog_streaming_enabled().execute() + + +class Test_systemlog_streaming_enabled: + def test_pass_when_active_stream_exists(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="EventBridge prod")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "EventBridge prod" in findings[0].status_extended + + def test_pass_when_multiple_active_streams(self): + client = build_systemlog_client( + log_streams={ + "log-1": log_stream(stream_id="log-1", name="A"), + "log-2": log_stream(stream_id="log-2", name="B"), + } + ) + findings = _run_check(client) + assert len(findings) == 2 + assert all(f.status == "PASS" for f in findings) + + def test_fail_when_all_streams_inactive(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="A", status="INACTIVE")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "none are ACTIVE" in findings[0].status_extended + + def test_fail_when_no_streams_configured(self): + client = build_systemlog_client(log_streams={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Log Streams are configured" in findings[0].status_extended + assert "mutelist" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_systemlog_client( + missing_scope={"log_streams": "okta.logStreams.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.logStreams.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/user/user_fixtures.py b/tests/providers/okta/services/user/user_fixtures.py new file mode 100644 index 0000000000..99282c1efa --- /dev/null +++ b/tests/providers/okta/services/user/user_fixtures.py @@ -0,0 +1,55 @@ +"""Shared helpers for `user` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + UserAutomation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_user_client( + automations: dict = None, + external_directory_idps: dict = None, + audit_config: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.automations = automations or {} + client.external_directory_idps = external_directory_idps or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + client.missing_scope = missing_scope or { + "automations": None, + "identity_providers": None, + } + return client + + +def automation( + automation_id: str = "auto-1", + name: str = "User Inactivity", + status: str = "ACTIVE", + schedule_status: str = "ACTIVE", + inactivity_days: int = 35, + lifecycle_action: str = "SUSPENDED", + groups: list = None, +): + # `groups is None` keeps the "Everyone-equivalent" default; passing + # `groups=[]` lets a test exercise the empty-scope FAIL path. + return UserAutomation( + id=automation_id, + name=name, + status=status, + schedule_status=schedule_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=["everyone"] if groups is None else groups, + ) + + +def ad_idp(idp_id: str = "0oa-ad", name: str = "Corp AD"): + return ExternalDirectoryIdp( + id=idp_id, name=name, type="ACTIVE_DIRECTORY", status="ACTIVE" + ) diff --git a/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py new file mode 100644 index 0000000000..99cb7db6a0 --- /dev/null +++ b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py @@ -0,0 +1,165 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.user.user_fixtures import ( + ad_idp, + automation, + build_user_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.user." + "user_inactivity_automation_35d_enabled." + "user_inactivity_automation_35d_enabled.user_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.user.user_inactivity_automation_35d_enabled.user_inactivity_automation_35d_enabled import ( + user_inactivity_automation_35d_enabled, + ) + + return user_inactivity_automation_35d_enabled().execute() + + +class Test_user_inactivity_automation_35d_enabled: + def test_pass_when_compliant_automation_present(self): + client = build_user_client( + automations={"auto-1": automation(name="Inactivity 35d")} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Inactivity 35d" in findings[0].status_extended + assert "SUSPENDED" in findings[0].status_extended + + def test_pass_message_names_groups_and_asks_for_coverage_verification(self): + # Okta has no built-in Everyone group ID and group names vary by + # tenant (e.g. "pepito"), so we can't assert tenant-wide coverage + # automatically — surface the group IDs and let the operator verify. + client = build_user_client( + automations={"auto-1": automation(groups=["grp-A", "grp-B"])} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "grp-A, grp-B" in findings[0].status_extended + assert "cover every user" in findings[0].status_extended + + def test_fail_when_applies_to_no_group(self): + # An automation with empty `people.groups.include` runs against + # nobody — Okta does not implicitly cover every user. + client = build_user_client(automations={"auto-1": automation(groups=[])}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no group scope" in findings[0].status_extended + + def test_pass_when_lower_threshold(self): + # Inactivity threshold lower than the default is still compliant. + client = build_user_client( + automations={"auto-1": automation(inactivity_days=14)} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_threshold_too_high(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "inactivity 90d (max 35d)" in findings[0].status_extended + + def test_fail_when_status_inactive(self): + client = build_user_client( + automations={"auto-1": automation(status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "status INACTIVE" in findings[0].status_extended + + def test_fail_when_schedule_inactive(self): + client = build_user_client( + automations={"auto-1": automation(schedule_status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "schedule INACTIVE" in findings[0].status_extended + + def test_fail_when_wrong_lifecycle_action(self): + client = build_user_client( + automations={"auto-1": automation(lifecycle_action="ACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "action ACTIVE" in findings[0].status_extended + + def test_fail_when_no_automations(self): + client = build_user_client(automations={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Workflows automations" in findings[0].status_extended + + def test_fail_lists_every_missing_piece_for_unfinished_automation(self): + # Mirrors the real-world case where an admin clicks "Add Automation" + # in the UI but never configures conditions or actions. The service + # emits a placeholder UserAutomation so the check FAILs with a + # specific message instead of pretending the policy doesn't exist. + from prowler.providers.okta.services.user.user_service import UserAutomation + + shell = UserAutomation( + id="pol-1", + name="TestCheck", + status="INACTIVE", + schedule_status="INACTIVE", + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id="pol-1", + policy_name="TestCheck", + ) + client = build_user_client(automations={"pol-1": shell}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + msg = findings[0].status_extended + assert "TestCheck" in msg + assert "status INACTIVE" in msg + assert "schedule INACTIVE" in msg + assert "no inactivity condition" in msg + assert "action unset" in msg + + def test_manual_na_when_external_directory_idp_present(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)}, # non-compliant + external_directory_idps={"0oa-ad": ad_idp(name="Corp AD")}, + ) + findings = _run_check(client) + # External directory short-circuits to MANUAL N/A regardless of + # the automations state. + assert findings[0].status == "MANUAL" + assert "ACTIVE_DIRECTORY" in findings[0].status_extended + assert "Corp AD" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_user_client( + missing_scope={ + "automations": "okta.policies.read", + "identity_providers": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=60)}, + audit_config={"okta_user_inactivity_max_days": 90}, + ) + findings = _run_check(client) + assert findings[0].status == "PASS" diff --git a/tests/providers/okta/services/user/user_service_test.py b/tests/providers/okta/services/user/user_service_test.py new file mode 100644 index 0000000000..f4e0309b7b --- /dev/null +++ b/tests/providers/okta/services/user/user_service_test.py @@ -0,0 +1,477 @@ +import json +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + User, + UserAutomation, + _raw_rule_to_automation, + _rule_to_automation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_policy( + policy_id, + name="Inactivity Policy", + status="ACTIVE", + inactivity_days=35, + inactivity_unit="DAYS", + groups=None, +): + # In the actual API response, the inactivity condition and the + # group scope live on the *policy*, not on its rules — keep the + # typed fixture aligned with that shape so it mirrors raw JSON. + p = mock.MagicMock() + p.id = policy_id + p.name = name + p.status = status + if inactivity_days is None: + p.conditions.people.users.inactivity = None + else: + p.conditions.people.users.inactivity.number = inactivity_days + p.conditions.people.users.inactivity.unit = inactivity_unit + p.conditions.people.groups.include = ["everyone"] if groups is None else groups + return p + + +def _fake_rule( + rule_id="rule-1", + name="Inactivity", + status="ACTIVE", + lifecycle_action="SUSPENDED", +): + # A USER_LIFECYCLE policy rule carries only the lifecycle action; + # its `conditions` is typically empty. + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.status = status + r.actions.user_lifecycle.action = lifecycle_action + return r + + +def _fake_idp(idp_type, status="ACTIVE", idp_id="0oa-1", name="x"): + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = idp_type + idp.status = status + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_rule_to_automation: + def test_parses_inactivity_and_lifecycle(self): + policy = _fake_policy("pol-1", name="Inactivity Policy") + rule = _fake_rule(rule_id="rule-1", name="Inactivity") + m = _rule_to_automation(rule, policy) + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "Inactivity Policy" + + def test_returns_none_when_id_missing(self): + policy = _fake_policy("pol") + bad = _fake_rule() + bad.id = "" + assert _rule_to_automation(bad, policy) is None + + def test_ignores_non_days_unit(self): + policy = _fake_policy("pol", inactivity_unit="WEEKS") + rule = _fake_rule() + m = _rule_to_automation(rule, policy) + assert m.inactivity_days is None + + def test_reads_inactivity_and_groups_from_policy_not_rule(self): + # The typed path used to read inactivity/groups from the rule; + # an SDK update that started populating `policy.conditions` + # exposed the mismatch. Locking the policy-shaped projection in. + policy = _fake_policy("pol", inactivity_days=21, groups=["grp-x"]) + rule = _fake_rule() + # Sanity: nothing inactivity-ish on the rule. + del rule.conditions + m = _rule_to_automation(rule, policy) + assert m.inactivity_days == 21 + assert m.applies_to_groups == ["grp-x"] + + +class Test_User_service: + def test_fetches_automations_via_policy_api(self): + provider = set_mocked_okta_provider() + policy = _fake_policy("pol-1") + rule = _fake_rule(rule_id="rule-1") + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_rules(*_a, **_k): + return ([rule], _resp({}), None) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = fake_list_rules + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.external_directory_idps == {} + + def test_returns_empty_on_policies_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = failing + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert service.automations == {} + + def test_detects_external_directory_idp(self): + provider = set_mocked_okta_provider() + + async def empty_policies(*_a, **_k): + return ([], _resp({}), None) + + ad = _fake_idp("ACTIVE_DIRECTORY", idp_id="0oa-ad", name="Corp AD") + saml = _fake_idp("SAML2", idp_id="0oa-saml") + + async def fake_list_idps(*_a, **_k): + return ([ad, saml], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = empty_policies + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "0oa-ad" in service.external_directory_idps + assert "0oa-saml" not in service.external_directory_idps + assert isinstance( + service.external_directory_idps["0oa-ad"], ExternalDirectoryIdp + ) + + +class Test_raw_rule_to_automation: + def test_projects_inactivity_and_lifecycle(self): + # Real API shape: inactivity + groups live on the POLICY, + # lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus`. + policy = { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + "type": "USER_LIFECYCLE", + } + rule = { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + m = _raw_rule_to_automation(rule, policy, "pol-1", "TestCheck", "ACTIVE") + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "TestCheck" + + def test_returns_none_when_id_missing(self): + assert _raw_rule_to_automation({"name": "x"}, {}, "pol", "P", "ACTIVE") is None + + def test_ignores_non_days_unit(self): + policy = { + "id": "pol", + "conditions": { + "people": {"users": {"inactivity": {"number": 5, "unit": "WEEKS"}}} + }, + } + rule = {"id": "rule-2", "actions": {}} + m = _raw_rule_to_automation(rule, policy, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + + def test_missing_policy_dict_gives_empty_inactivity_and_groups(self): + rule = { + "id": "rule-3", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + m = _raw_rule_to_automation(rule, None, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + assert m.applies_to_groups == [] + assert m.lifecycle_action == "SUSPENDED" + + +class Test_User_service_sdk_discriminator_fallback: + """Verifies the raw-JSON fallback when the SDK can't deserialize USER_LIFECYCLE. + + Okta SDK 3.4.2 ships a `Policy.from_dict` discriminator mapping that + omits `USER_LIFECYCLE`, so the typed call raises ValueError. Without + the fallback the whole automations list is lost; with it the raw + JSON path projects each rule onto a `UserAutomation` snapshot. + """ + + def test_raw_fallback_projects_user_lifecycle_policy_rules(self): + provider = set_mocked_okta_provider() + + # Real API shape: inactivity + groups on POLICY, lifecycle + # action on RULE under `actions.updateUserLifecycle.targetStatus`. + policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + ] + + async def failing_list_policies(*_a, **_k): + raise ValueError( + "Policy failed to lookup discriminator value from {...}. " + "Discriminator property name: type, mapping: {...}" + ) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + url = kwargs.get("url", "") or "" + return ({"url": url}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + a = service.automations["rule-1"] + assert a.inactivity_days == 35 + assert a.lifecycle_action == "SUSPENDED" + assert a.schedule_status == "ACTIVE" + assert a.policy_id == "pol-1" + assert a.policy_name == "TestCheck" + + def test_raw_fallback_emits_shell_for_policy_with_no_rules(self): + # Mirrors the real-world tenant state where an admin clicked + # "Add Automation" in the UI but never configured conditions or + # actions. The policy exists; it has zero rules. The raw fallback + # must surface the policy as a shell UserAutomation so the check + # FAILs with a specific message instead of dropping it. + provider = set_mocked_okta_provider() + + async def failing_list_policies(*_a, **_k): + raise ValueError("missing discriminator mapping") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-empty/rules" in url: + return (None, "[]", None) + if "/api/v1/policies" in url: + return ( + None, + json.dumps( + [ + { + "id": "pol-empty", + "name": "TestCheck", + "status": "INACTIVE", + "type": "USER_LIFECYCLE", + } + ] + ), + None, + ) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "pol-empty" in service.automations + shell = service.automations["pol-empty"] + assert shell.name == "TestCheck" + assert shell.status == "INACTIVE" + assert shell.schedule_status == "INACTIVE" + assert shell.inactivity_days is None + assert shell.lifecycle_action is None + assert shell.applies_to_groups == [] + assert shell.policy_id == "pol-empty" + + def test_rule_typed_failure_triggers_raw_fallback_for_all_policies(self): + # When the typed `list_policies` succeeds but the typed + # `list_policy_rules` fails for a policy, the previous behavior + # was to emit a shell automation — silently misclassifying a + # valid automation as "unfinished". Now `_fetch_rules` returns + # None as a sentinel and the caller re-runs the entire + # discovery via raw JSON so no rule data is lost. + provider = set_mocked_okta_provider() + + typed_policy = _fake_policy( + "pol-1", name="TestCheck", inactivity_days=35, groups=["everyone"] + ) + + async def fake_list_policies(*_a, **_k): + return ([typed_policy], _resp({}), None) + + async def failing_list_policy_rules(*_a, **_k): + raise ValueError("KnowledgeConstraint.types expected uppercase") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + raw_policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + raw_rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + ] + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(raw_rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(raw_policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = failing_list_policy_rules + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + # Raw-projected automation, not a shell. + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.automations["rule-1"].lifecycle_action == "SUSPENDED" From 1c9afc714e3580fb0d847f0fbb6384290cf8acb5 Mon Sep 17 00:00:00 2001 From: Aline Almeida Date: Mon, 8 Jun 2026 16:46:48 +0200 Subject: [PATCH 019/129] fix(gcp): honour org-aggregated sinks in metric-filter checks (#11488) Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 1 + ...for_audit_configuration_changes_enabled.py | 22 ++- ...t_for_bucket_permission_changes_enabled.py | 20 +- ...r_compute_configuration_changes_enabled.py | 17 +- ...d_alert_for_custom_role_changes_enabled.py | 20 +- ...t_for_project_ownership_changes_enabled.py | 20 +- ..._instance_configuration_changes_enabled.py | 17 +- ...t_for_vpc_firewall_rule_changes_enabled.py | 20 +- ...d_alert_for_vpc_network_changes_enabled.py | 20 +- ...t_for_vpc_network_route_changes_enabled.py | 20 +- .../gcp/services/logging/logging_service.py | 57 ++++++ ...udit_configuration_changes_enabled_test.py | 173 ++++++++++++++++++ ..._bucket_permission_changes_enabled_test.py | 170 +++++++++++++++++ ...pute_configuration_changes_enabled_test.py | 170 +++++++++++++++++ ...rt_for_custom_role_changes_enabled_test.py | 170 +++++++++++++++++ ..._project_ownership_changes_enabled_test.py | 170 +++++++++++++++++ ...ance_configuration_changes_enabled_test.py | 170 +++++++++++++++++ ..._vpc_firewall_rule_changes_enabled_test.py | 170 +++++++++++++++++ ...rt_for_vpc_network_changes_enabled_test.py | 170 +++++++++++++++++ ..._vpc_network_route_changes_enabled_test.py | 170 +++++++++++++++++ .../services/logging/logging_service_test.py | 163 +++++++++++++++++ 21 files changed, 1882 insertions(+), 48 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 45cf485716..1389b684cf 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) - AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470) diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py index 4654be4d29..84bf078dac 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,12 +13,10 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -33,6 +34,11 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable break findings.append(report) + # Credit projects whose logs are centrally monitored via an org-level + # aggregated sink to a bucket-scoped metric + alert (instead of failing them). + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -46,8 +52,12 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py index 166f7b7ee8..e7d74f3f8e 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -46,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py index 7902f9ed72..cf7cdb1679 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.serviceName="compute.googleapis.com"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py index 1e6584e5fb..f836dc25b2 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py index 8c8927ec32..b7bc619ea4 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -47,8 +51,12 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py index 3e499db10a..3c03ab0fde 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="cloudsql.instances.update"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py index e2b7cdcc13..0e05838f05 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py index c8b15ce1ee..1330ad7a9a 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py index f840d75852..27f25879e8 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 2459895c4c..3d5b0d1e79 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -90,6 +90,7 @@ class Logging(GCPService): type=metric["metricDescriptor"]["type"], filter=metric["filter"], project_id=project_id, + bucket_name=metric.get("bucketName", ""), ) ) @@ -117,3 +118,59 @@ class Metric(BaseModel): type: str filter: str project_id: str + bucket_name: str = "" + + +def get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter +): + """Return {project_id: metric_name} for scanned projects whose logs are routed, + via an organization-level sink with includeChildren=True, to a bucket that holds + a bucket-scoped log metric matching ``metric_filter`` that has an alert policy. + + The CIS GCP logging-metric checks are written per-project, but a common (and + recommended) topology centralizes monitoring: an org-level aggregated sink ships + every child project's logs into one bucket, where a single bucket-scoped metric + + alert covers them all. Without crediting that, those child projects are falsely + failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355). + """ + # Buckets that hold a matching, alerted, bucket-scoped metric -> metric name. + bucket_to_metric = {} + for metric in logging_client.metrics: + if not getattr(metric, "bucket_name", ""): + continue + if metric_filter not in metric.filter: + continue + if any( + metric.name in policy_filter + for alert_policy in monitoring_client.alert_policies + for policy_filter in alert_policy.filters + ): + bucket_to_metric[metric.bucket_name] = metric.name + if not bucket_to_metric: + return {} + + # Org resources whose includeChildren sink targets one of those buckets. + org_to_metric = {} + for sink in logging_client.sinks: + if not getattr(sink, "include_children", False): + continue + if getattr(sink, "filter", "all") != "all": + continue + for bucket, metric_name in bucket_to_metric.items(): + # sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X"; + # metric.bucket_name e.g. "projects/.../buckets/X". + if sink.destination.endswith(bucket): + org_to_metric[sink.project_id] = metric_name + break + if not org_to_metric: + return {} + + # Scanned projects sitting under a covering organization. + covered = {} + for project_id in logging_client.project_ids: + project = logging_client.projects.get(project_id) + organization = getattr(project, "organization", None) if project else None + if organization and f"organizations/{organization.id}" in org_to_metric: + covered[project_id] = org_to_metric[f"organizations/{organization.id}"] + return covered diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py index a38f97d81c..c0dc6b0a06 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py @@ -259,3 +259,176 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally) instead of + being falsely failed.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + # Bucket-scoped central metric, in the scanned logging project. + logging_client.metrics = [ + Metric( + name="central-audit-config-metric", + type="logging.googleapis.com/user/central-audit-config-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + # Org-level aggregated sink routing the child's logs to that bucket. + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-audit-config-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py index e2b7b2d068..e9eeabf430 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py @@ -397,3 +397,173 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py index 563b4e49ac..5347e3e42e 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py @@ -346,3 +346,173 @@ class Test_logging_log_metric_filter_and_alert_for_compute_configuration_changes fail_result = [r for r in result if r.status == "FAIL"][0] assert fail_result.project_id == project_id_2 assert "no log metric filters" in fail_result.status_extended + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py index 4ec94be657..18d1807255 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py index 0ea0798e03..3adb48e567 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py @@ -392,3 +392,173 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py index 1a8a1d0da3..9444f0cc61 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py index a9460e6b46..3d34a3c295 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py index 9c59d56a81..a71a21ceb6 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py index 254c41bb5f..3a7f41a485 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 49368d0289..72e18eddbb 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -137,3 +137,166 @@ class TestLoggingService: s for s in logging_svc.sinks if s.project_id.startswith("organizations/") ] assert org_sinks == [] + + def test_get_metrics_populates_bucket_name(self): + """_get_metrics() captures a metric's bucketName (for aggregated-sink crediting).""" + bucket = "projects/central-logging-project/locations/eu/buckets/central-bucket" + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = {"sinks": []} + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = { + "metrics": [ + { + "name": "central-metric", + "metricDescriptor": { + "type": "logging.googleapis.com/user/central-metric" + }, + "filter": "severity>=ERROR", + "bucketName": bucket, + } + ] + } + mock_client.projects().metrics().list_next.return_value = None + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=mock_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + metrics = [m for m in logging_svc.metrics if m.name == "central-metric"] + assert len(metrics) == 1 + assert metrics[0].bucket_name == bucket + + +class TestGetProjectsCoveredByAggregatedMetric: + """Unit tests for the aggregated-sink crediting helper: one positive case and the + guards that must NOT credit a project (so the metric-filter checks never false-pass). + """ + + FILTER = 'protoPayload.methodName="SetIamPolicy"' + ORG = "111222333" + BUCKET = "projects/central-logging-project/locations/eu/buckets/central-bucket" + + def _clients( + self, + *, + include_children=True, + bucket_name=None, + sink_destination=None, + sink_filter="all", + with_alert=True, + project_org_id=None, + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_service import Metric, Sink + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + bucket_name = self.BUCKET if bucket_name is None else bucket_name + sink_destination = ( + f"logging.googleapis.com/{self.BUCKET}" + if sink_destination is None + else sink_destination + ) + project_org_id = self.ORG if project_org_id is None else project_org_id + + logging_client = MagicMock() + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=project_org_id, name=f"organizations/{project_org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter=self.FILTER, + project_id="central-logging-project", + bucket_name=bucket_name, + ) + ] + logging_client.sinks = [ + Sink( + name="org-sink", + destination=sink_destination, + filter=sink_filter, + project_id=f"organizations/{self.ORG}", + include_children=include_children, + ) + ] + monitoring_client = MagicMock() + monitoring_client.alert_policies = ( + [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + if with_alert + else [] + ) + return logging_client, monitoring_client + + def _run(self, logging_client, monitoring_client): + from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, + ) + + return get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, self.FILTER + ) + + def test_covered_when_all_conditions_met(self): + logging_client, monitoring_client = self._clients() + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_not_covered_without_alert(self): + logging_client, monitoring_client = self._clients(with_alert=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_metric_not_bucket_scoped(self): + logging_client, monitoring_client = self._clients(bucket_name="") + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_not_include_children(self): + logging_client, monitoring_client = self._clients(include_children=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_filter_is_restrictive(self): + logging_client, monitoring_client = self._clients( + sink_filter='resource.type="gce_instance"' + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_destination_bucket_differs(self): + logging_client, monitoring_client = self._clients( + sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_project_org_differs(self): + logging_client, monitoring_client = self._clients(project_org_id="999999999") + assert self._run(logging_client, monitoring_client) == {} From 7692a1d76a1dabb74adf2eda13f47e4db2b23b09 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:51:58 +0200 Subject: [PATCH 020/129] feat(okta): add network zone STIG check (#11463) Co-authored-by: Daniel Barranquero --- .../providers/okta/authentication.mdx | 10 +- .../providers/okta/getting-started-okta.mdx | 14 +- prowler/CHANGELOG.md | 1 + prowler/providers/okta/okta_provider.py | 1 + .../okta/services/network/__init__.py | 0 .../okta/services/network/lib/__init__.py | 0 .../network/lib/network_zone_helpers.py | 88 +++ .../__init__.py | 0 ...one_block_anonymized_proxies.metadata.json | 37 ++ .../network_zone_block_anonymized_proxies.py | 77 +++ .../services/network/network_zone_client.py | 4 + .../services/network/network_zone_service.py | 234 ++++++++ tests/providers/okta/okta_fixtures.py | 2 + ...work_zone_block_anonymized_proxies_test.py | 153 +++++ .../network_zone/network_zone_fixtures.py | 42 ++ .../network_zone/network_zone_service_test.py | 560 ++++++++++++++++++ 16 files changed, 1213 insertions(+), 10 deletions(-) create mode 100644 prowler/providers/okta/services/network/__init__.py create mode 100644 prowler/providers/okta/services/network/lib/__init__.py create mode 100644 prowler/providers/okta/services/network/lib/network_zone_helpers.py create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py create mode 100644 prowler/providers/okta/services/network/network_zone_client.py create mode 100644 prowler/providers/okta/services/network/network_zone_service.py create mode 100644 tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py create mode 100644 tests/providers/okta/services/network_zone/network_zone_fixtures.py create mode 100644 tests/providers/okta/services/network_zone/network_zone_service_test.py diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index ca56a0535b..67eb72e153 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -35,6 +35,7 @@ The bundled checks require the following read-only scopes: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.networkZones.read` - `okta.logStreams.read` - `okta.idps.read` @@ -45,6 +46,7 @@ Additional scopes will be needed as more services and checks are added. These ar | `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | | `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | +| `okta.networkZones.read` | Network Zone inventory and anonymized-proxy blocklist checks | | `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) | | `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) | @@ -128,7 +130,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 5. Grant the required OAuth scopes -On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) @@ -164,8 +166,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" uv run python prowler-cli.py okta ``` @@ -206,7 +208,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: -- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. ### Application-service checks return MANUAL on first-party apps diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 6086afefb3..740cfbf729 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -147,6 +147,7 @@ Prowler for Okta includes security checks across the following services: | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | | **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| **Network** | Network Zone blocklists for anonymized proxy sources | | **User** | User lifecycle automations (inactivity-based deprovisioning) | | **System Log** | Log Stream configuration that off-loads audit records to a central SIEM | | **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility | @@ -161,11 +162,12 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, User, System Log, and Identity Provider services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, User, System Log, and Identity Provider services: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.networkZones.read` - `okta.logStreams.read` - `okta.idps.read` @@ -175,10 +177,10 @@ When additional checks are enabled — or when running against a service app tha ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.logStreams.read okta.idps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.logStreams.read okta.idps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 1389b684cf..fab236e9e1 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 678e9edbd4..4356adbc57 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -36,6 +36,7 @@ DEFAULT_SCOPES = [ "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.networkZones.read", "okta.logStreams.read", "okta.idps.read", ] diff --git a/prowler/providers/okta/services/network/__init__.py b/prowler/providers/okta/services/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/__init__.py b/prowler/providers/okta/services/network/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/network_zone_helpers.py b/prowler/providers/okta/services/network/lib/network_zone_helpers.py new file mode 100644 index 0000000000..bcd906b01a --- /dev/null +++ b/prowler/providers/okta/services/network/lib/network_zone_helpers.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, + OktaNetworkZone, +) + +ANONYMIZER_CATEGORY_MARKERS = ( + "ANONYM", + "PROXY", + "TOR", + "VPN", +) + + +def active_blocklist_zones( + network_zones: dict[str, OktaNetworkZone], +) -> list[OktaNetworkZone]: + """Return active Network Zones configured for blocklist usage.""" + return sorted( + [ + zone + for zone in network_zones.values() + if zone.status.upper() == "ACTIVE" and zone.usage.upper() == "BLOCKLIST" + ], + key=lambda zone: (zone.name, zone.id), + ) + + +def is_ip_blocklist_with_entries(zone: OktaNetworkZone) -> bool: + """Return True when an IP blocklist zone contains gateway/proxy entries.""" + return zone.type.upper() == "IP" and bool(zone.gateways or zone.proxies) + + +def is_enhanced_dynamic_anonymizer_blocklist(zone: OktaNetworkZone) -> bool: + """Return True for active Enhanced Dynamic blocklists covering anonymizers.""" + if zone.type.upper() != "DYNAMIC_V2": + return False + categories = [category.upper() for category in zone.ip_service_categories] + return any( + marker in category + for category in categories + for marker in ANONYMIZER_CATEGORY_MARKERS + ) + + +def compliant_anonymized_proxy_blocklist( + network_zones: dict[str, OktaNetworkZone], +) -> tuple[OktaNetworkZone | None, str]: + """Find the Network Zone that satisfies anonymized-proxy blocklisting.""" + for zone in active_blocklist_zones(network_zones): + if is_enhanced_dynamic_anonymizer_blocklist(zone): + return zone, "active Enhanced Dynamic Zone blocklist for anonymizers" + return None, "" + + +def static_ip_blocklist_evidence( + network_zones: dict[str, OktaNetworkZone], +) -> OktaNetworkZone | None: + """Return static IP blocklist evidence that requires human validation.""" + for zone in active_blocklist_zones(network_zones): + if is_ip_blocklist_with_entries(zone): + return zone + return None + + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_network_zone_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Network Zones cannot be listed.""" + resource = NetworkZoneSummary( + id="network-zones-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Network Zones: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json new file mode 100644 index 0000000000..d2a4791c17 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "network_zone_block_anonymized_proxies", + "CheckTitle": "Okta uses active Network Zone blocklists for anonymized proxy sources", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Okta Network Zone blocklists** should block anonymized proxy access before authentication. Enhanced Dynamic Zone anonymizer categories provide direct coverage; static IP blocklists show gateway/proxy blocking but cannot prove full anonymizer-provider coverage.", + "Risk": "**Anonymized proxy access** lets attackers hide source networks while attempting credential attacks, session establishment, or policy bypass from untrusted infrastructure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/networkzone", + "https://help.okta.com/en-us/content/topics/security/network/about-enhanced-dynamic-zones.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > Networks: configure BlockedIpZone gateway/proxy IP entries or activate DefaultEnhancedDynamicZone / Enhanced Dynamic Zone blocklisting for anonymizers.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Prefer an active Enhanced Dynamic Zone blocklist** for anonymizers. If you use a static IP Network Zone blocklist, keep gateway/proxy entries actively maintained because Prowler cannot prove full anonymizer-provider coverage from static entries alone.", + "Url": "https://hub.prowler.com/check/network_zone_block_anonymized_proxies" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py new file mode 100644 index 0000000000..3da973b415 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py @@ -0,0 +1,77 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.network.lib.network_zone_helpers import ( + compliant_anonymized_proxy_blocklist, + missing_network_zone_scope_finding, + static_ip_blocklist_evidence, +) +from prowler.providers.okta.services.network.network_zone_client import ( + network_zone_client, +) +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, +) + + +class network_zone_block_anonymized_proxies(Check): + """Ensure Okta actively blocks anonymized proxy sources before auth.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate whether an active blocklist covers anonymized proxies.""" + org_domain = network_zone_client.provider.identity.org_domain + missing_scope = network_zone_client.missing_scope.get("network_zones") + if missing_scope: + return [ + missing_network_zone_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ] + + retrieval_error = getattr(network_zone_client, "retrieval_error", None) + if retrieval_error: + resource = NetworkZoneSummary( + id="network-zones-retrieval-error", + name="(retrieval failed)", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Okta Network Zones could not be retrieved or validated. " + f"Reason: {retrieval_error}" + ) + return [report] + + matching_zone, reason = compliant_anonymized_proxy_blocklist( + network_zone_client.network_zones + ) + manual_zone = ( + None + if matching_zone + else static_ip_blocklist_evidence(network_zone_client.network_zones) + ) + + resource = matching_zone or manual_zone or NetworkZoneSummary() + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if matching_zone: + report.status = "PASS" + report.status_extended = ( + f"Okta Network Zone {matching_zone.name} is an {reason}." + ) + elif manual_zone: + report.status = "MANUAL" + report.status_extended = ( + f"Okta Network Zone {manual_zone.name} is an active manual IP " + "blocklist with gateway or proxy IP entries; Prowler cannot " + "verify full anonymizer coverage for static entries." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "No active Okta Network Zone blocklist was found that blocks " + "anonymized proxies. Existing zones do not actively block gateway " + "or proxy IPs, nor an Enhanced Dynamic Zone anonymizer category." + ) + return [report] diff --git a/prowler/providers/okta/services/network/network_zone_client.py b/prowler/providers/okta/services/network/network_zone_client.py new file mode 100644 index 0000000000..6d4f603f80 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.network.network_zone_service import NetworkZone + +network_zone_client = NetworkZone(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/network/network_zone_service.py b/prowler/providers/okta/services/network/network_zone_service.py new file mode 100644 index 0000000000..550c9b0d49 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_service.py @@ -0,0 +1,234 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "network_zones": "okta.networkZones.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +class NetworkZone(OktaService): + """Fetches Okta Network Zones for STIG network-zone checks.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.retrieval_error: str | None = None + self.network_zones: dict[str, OktaNetworkZone] = ( + {} if self.missing_scope["network_zones"] else self._list_network_zones() + ) + + def _set_retrieval_error(self, message: str) -> None: + self.retrieval_error = message + logger.error(message) + + def _list_network_zones(self) -> dict[str, "OktaNetworkZone"]: + """List all Network Zones visible to the configured Okta service app.""" + logger.info("NetworkZone - Listing Okta Network Zones...") + try: + return self._run(self._fetch_all()) + except Exception as error: + line_number = getattr(error.__traceback__, "tb_lineno", "unknown") + self._set_retrieval_error( + f"{error.__class__.__name__}[{line_number}]: {error}" + ) + return {} + + async def _fetch_all(self) -> dict[str, "OktaNetworkZone"]: + result: dict[str, OktaNetworkZone] = {} + try: + all_zones, err = await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + # Upstream Okta SDK ↔ Management API schema drift: the SDK + # generates `EnhancedDynamicNetworkZoneAllOfAsnsInclude` as an + # object-shaped pydantic model, but the API returns + # `asns.include` as a JSON array (typically `[]`), so pydantic + # rejects the whole zone with `model_type` errors. Fall back + # to a raw-JSON fetch so STIG evaluation isn't blocked by an + # upstream SDK bug. Same workaround shape as + # `application_service._fetch_access_policy_raw`. The wider + # `(ValueError, ValidationError)` catch matches the + # `user_service` precedent — the SDK raises either depending + # on whether the failure is a discriminator miss or a model + # mismatch. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones — " + "falling back to raw-JSON parse. This is an okta-sdk-python " + "deserialization bug; the workaround should be removed once " + "upstream fixes it." + ) + return await self._fetch_all_raw() + if err is not None: + self._set_retrieval_error(f"Error listing Network Zones: {err}") + return result + + for zone in all_zones: + zone_obj = self._build_zone(zone) + result[zone_obj.id] = zone_obj + return result + + async def _fetch_all_raw(self) -> dict[str, "OktaNetworkZone"]: + """Raw-JSON fallback for `list_network_zones`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then projects each zone onto our + own pydantic snapshot — which only validates the fields the + STIG checks actually read. + """ + result: dict[str, OktaNetworkZone] = {} + zones_data = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones", + ) + if zones_data is None: + self._set_retrieval_error( + "Raw Network Zones fetch failed; see logs for details." + ) + return result + for zone_dict in zones_data: + if not isinstance(zone_dict, dict): + continue + zone_obj = _raw_zone_to_model(zone_dict) + result[zone_obj.id] = zone_obj + return result + + @staticmethod + def _build_zone(zone) -> "OktaNetworkZone": + zone_id = _value(getattr(zone, "id", None)) + return OktaNetworkZone( + id=zone_id, + name=_value(getattr(zone, "name", None)) or zone_id, + status=_value(getattr(zone, "status", None)), + type=_value(getattr(zone, "type", None)), + usage=_value(getattr(zone, "usage", None)), + system=bool(getattr(zone, "system", False)), + gateways=_address_values(getattr(zone, "gateways", None)), + proxies=_address_values(getattr(zone, "proxies", None)), + asns=_condition_values(getattr(zone, "asns", None)), + locations=_condition_values(getattr(zone, "locations", None)), + ip_service_categories=_condition_values( + getattr(zone, "ip_service_categories", None) + ), + ) + + +def _raw_zone_to_model(zone_dict: dict) -> "OktaNetworkZone": + """Project a raw `/api/v1/zones` JSON zone onto our model. + + Mirrors `NetworkZone._build_zone` but reads camelCase JSON keys + (`ipServiceCategories`) instead of the SDK's snake_case attributes. + Used by the raw-JSON fallback that activates when the Okta SDK's + strict pydantic validators reject zone payloads the Management API + returns (e.g. Enhanced Dynamic Zones with `asns.include: []`). + """ + zone_id = str(zone_dict.get("id") or "") + categories = _condition_values(zone_dict.get("ipServiceCategories")) + # IP-typed zones return `gateways`/`proxies` as `[{type, value}]` + # arrays; Enhanced Dynamic Zones return `asns`/`locations` and + # `ipServiceCategories` as `{include, exclude}` objects. Keep the + # `list[str]` shape by extracting address values and included + # condition values from both SDK models and raw JSON. + return OktaNetworkZone( + id=zone_id, + name=str(zone_dict.get("name") or zone_id), + status=str(zone_dict.get("status") or ""), + type=str(zone_dict.get("type") or ""), + usage=str(zone_dict.get("usage") or ""), + system=bool(zone_dict.get("system", False)), + gateways=_address_values(zone_dict.get("gateways")), + proxies=_address_values(zone_dict.get("proxies")), + asns=_condition_values(zone_dict.get("asns")), + locations=_condition_values(zone_dict.get("locations")), + ip_service_categories=categories, + ) + + +def _address_values(raw) -> list[str]: + """Return string values from an Okta address-style JSON array. + + Each entry in `gateways`/`proxies` is `{"type": ..., "value": ...}`; + `asns`/`locations` may be a `{include, exclude}` object on Enhanced + Dynamic Zones. Non-list inputs collapse to `[]` so the resulting + list satisfies the pydantic `list[str]` field. + """ + if not isinstance(raw, list): + return [] + out: list[str] = [] + for entry in raw: + if isinstance(entry, dict): + value = entry.get("value") + elif entry is not None: + value = getattr(entry, "value", entry) + else: + value = None + if value is not None: + out.append(_value(value)) + return out + + +def _condition_values(raw) -> list[str]: + """Return string values from Okta include/exclude-style conditions.""" + if raw is None: + return [] + values = ( + raw.get("include") if isinstance(raw, dict) else getattr(raw, "include", raw) + ) + if values is None: + return [] + if not isinstance(values, list): + values = [values] + normalized = [] + for value in values: + if isinstance(value, dict): + value = value.get("value") + if value is not None: + normalized.append(_value(value)) + return normalized + + +class OktaNetworkZone(BaseModel): + """Normalized Okta Network Zone attributes used by checks.""" + + id: str + name: str + status: str = "" + type: str = "" + usage: str = "" + system: bool = False + gateways: list[str] = Field(default_factory=list) + proxies: list[str] = Field(default_factory=list) + asns: list[str] = Field(default_factory=list) + locations: list[str] = Field(default_factory=list) + ip_service_categories: list[str] = Field(default_factory=list) + + +class NetworkZoneSummary(BaseModel): + """Synthetic resource for org-level Network Zone findings.""" + + id: str = "okta-network-zones" + name: str = "Okta Network Zones" diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 83c8812495..0ecb4a9eef 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -20,6 +20,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.networkZones.read", "okta.logStreams.read", "okta.idps.read", ], @@ -33,6 +34,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.networkZones.read", "okta.logStreams.read", "okta.idps.read", ], diff --git a/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py new file mode 100644 index 0000000000..89cc2fd8ed --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.network_zone.network_zone_fixtures import ( + build_network_zone_client, + network_zone, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.network." + "network_zone_block_anonymized_proxies." + "network_zone_block_anonymized_proxies.network_zone_client" +) + + +def _run_check(network_zone_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=network_zone_client), + ): + from prowler.providers.okta.services.network.network_zone_block_anonymized_proxies.network_zone_block_anonymized_proxies import ( + network_zone_block_anonymized_proxies, + ) + + return network_zone_block_anonymized_proxies().execute() + + +class Test_network_zone_block_anonymized_proxies: + def test_no_zones_fails(self): + findings = _run_check(build_network_zone_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Network Zone blocklist" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.networkZones.read" in findings[0].status_extended + + def test_sdk_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Error listing Network Zones: forbidden", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "forbidden" in findings[0].status_extended + + def test_raw_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Raw Network Zones fetch (execute) failed: timeout", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "timeout" in findings[0].status_extended + + def test_active_ip_blocklist_gateway_is_manual(self): + zone = network_zone(gateways=["198.51.100.10/32"]) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == zone.id + assert "manual IP blocklist" in findings[0].status_extended + assert "cannot verify full anonymizer coverage" in findings[0].status_extended + + def test_pass_with_active_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_pass_with_custom_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-custom-enhanced", + name="Custom Anonymous VPN Blocklist", + zone_type="DYNAMIC_V2", + system=False, + ip_service_categories=["VPN"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_dynamic_blocklist_without_anonymizer_category_fails(self): + zone = network_zone( + zone_id="nzo-dynamic", + name="Dynamic Blocklist Without Anonymizers", + zone_type="DYNAMIC", + ip_service_categories=["RISKY_IPS"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_default_enhanced_dynamic_zone_without_categories_fails(self): + zone = network_zone( + zone_id="nzo-default-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=[], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_existing_zones_without_anonymized_proxy_blocklist_fail(self): + policy_zone = network_zone( + zone_id="nzo-policy", + name="Corporate Policy Zone", + usage="POLICY", + gateways=["10.0.0.0/8"], + ) + inactive_blocklist = network_zone( + zone_id="nzo-inactive", + name="Inactive Blocklist", + status="INACTIVE", + gateways=["203.0.113.0/24"], + ) + findings = _run_check( + build_network_zone_client( + {policy_zone.id: policy_zone, inactive_blocklist.id: inactive_blocklist} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended diff --git a/tests/providers/okta/services/network_zone/network_zone_fixtures.py b/tests/providers/okta/services/network_zone/network_zone_fixtures.py new file mode 100644 index 0000000000..09328a5d73 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_fixtures.py @@ -0,0 +1,42 @@ +from unittest import mock + +from prowler.providers.okta.services.network.network_zone_service import OktaNetworkZone +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_network_zone_client( + zones: dict = None, + missing_scope: dict = None, + retrieval_error: str | None = None, +): + client = mock.MagicMock() + client.network_zones = zones or {} + client.missing_scope = missing_scope or {"network_zones": None} + client.retrieval_error = retrieval_error + client.provider = set_mocked_okta_provider() + return client + + +def network_zone( + zone_id: str = "nzo-1", + name: str = "BlockedIpZone", + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return OktaNetworkZone( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) diff --git a/tests/providers/okta/services/network_zone/network_zone_service_test.py b/tests/providers/okta/services/network_zone/network_zone_service_test.py new file mode 100644 index 0000000000..36790c1328 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_service_test.py @@ -0,0 +1,560 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from pydantic import ValidationError + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZone, + OktaNetworkZone, + _raw_zone_to_model, + _value, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_zone( + zone_id: str, + name: str, + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return SimpleNamespace( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) + + +class _ValueObject: + def __init__(self, value: str): + self.value = value + + +class Test_value_helper: + def test_value_returns_empty_string_for_none(self): + assert _value(None) == "" + + +class Test_NetworkZone_service: + def test_fetches_ip_and_enhanced_dynamic_zones(self): + provider = set_mocked_okta_provider() + ip_zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=["203.0.113.10/32"], + ) + enhanced_zone = _sdk_zone( + "nzo-enhanced", + "DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + + async def fake_list_network_zones(*_a, **_k): + return ([ip_zone, enhanced_zone], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = NetworkZone(provider) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + assert isinstance(service.network_zones["nzo-ip"], OktaNetworkZone) + assert service.network_zones["nzo-ip"].gateways == ["203.0.113.10/32"] + assert service.network_zones["nzo-enhanced"].type == "DYNAMIC_V2" + assert service.network_zones["nzo-enhanced"].ip_service_categories == [ + "ANONYMIZER" + ] + + def test_paginates_network_zones(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_zone("nzo-1", "First") + page_2 = _sdk_zone("nzo-2", "Second") + next_link = '; rel="next"' + calls = [] + + async def fake_list_network_zones(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert calls == [None, "cursor-2"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + def test_preserves_sdk_error_reason_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = failing + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == "Error listing Network Zones: forbidden" + + def test_build_zone_extracts_sdk_network_zone_address_values(self): + from okta.models.network_zone_address import NetworkZoneAddress + + zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=[ + NetworkZoneAddress( + type="CIDR", + value="203.0.113.10/32", + ) + ], + proxies=[ + NetworkZoneAddress( + type="CIDR", + value="198.51.100.10/32", + ) + ], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + + def test_build_zone_normalizes_sdk_value_objects_to_strings(self): + zone = _sdk_zone( + "nzo-sdk-values", + "SDK Values", + gateways=[_ValueObject("203.0.113.10/32")], + proxies=[_ValueObject("198.51.100.10/32")], + ) + zone.asns = SimpleNamespace(include=[_ValueObject("64512")], exclude=[]) + zone.locations = SimpleNamespace(include=[_ValueObject("US")], exclude=[]) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + assert built_zone.asns == ["64512"] + assert built_zone.locations == ["US"] + + def test_build_zone_extracts_sdk_enhanced_dynamic_category_values(self): + from okta.models.enhanced_dynamic_network_zone_all_of_ip_service_categories import ( + EnhancedDynamicNetworkZoneAllOfIpServiceCategories, + ) + + zone = _sdk_zone( + "nzo-enhanced", + "Enhanced Anonymizers", + zone_type="DYNAMIC_V2", + system=False, + ) + zone.ip_service_categories = EnhancedDynamicNetworkZoneAllOfIpServiceCategories( + include=["ALL_ANONYMIZERS"], + exclude=[], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_missing_network_zone_scope_skips_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.policies.read", "okta.brands.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.network_zones == {} + + +class Test_NetworkZone_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback for the Okta SDK Enhanced Dynamic + Zone deserialization bug. + + The Okta Management API returns `asns.include` as a JSON array + (typically `[]`) but the SDK's `EnhancedDynamicNetworkZoneAllOfAsnsInclude` + is an object-shaped pydantic model — so listing zones raises + ValidationError. Without a fallback the whole fetch crashes and + every check FAILs as if no zones exist; with the fallback we parse + the raw JSON and STIG evaluation continues. + """ + + @staticmethod + def _trigger_real_validation_error() -> ValidationError: + try: + from okta.models.enhanced_dynamic_network_zone_all_of_asns_include import ( # noqa: E501 + EnhancedDynamicNetworkZoneAllOfAsnsInclude, + ) + + EnhancedDynamicNetworkZoneAllOfAsnsInclude.from_dict([]) + except ValidationError as ve: + return ve + raise AssertionError("Expected pydantic ValidationError from Okta SDK model") + + def _build_service_with_raw_payload( + self, raw_zones_payload, response=None, body_factory=None + ): + response_body = ( + body_factory(raw_zones_payload) + if body_factory + else json.dumps(raw_zones_payload) + ) + return self._build_service_with_raw_response(response_body, response=response) + + def _build_service_with_raw_response( + self, response_body, response=None, execute_error=None + ): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/zones"}, None) + + async def fake_raw_execute(_request): + return (response, response_body, execute_error) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + return NetworkZone(provider) + + def test_raw_fallback_projects_ip_and_enhanced_dynamic_zones(self): + zones_payload = [ + { + "id": "nzo-ip", + "name": "Blocked IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [{"type": "CIDR", "value": "203.0.113.10/32"}], + "proxies": [], + }, + { + "id": "nzo-enhanced", + "name": "DefaultEnhancedDynamicZone", + "status": "ACTIVE", + "type": "DYNAMIC_V2", + "usage": "BLOCKLIST", + "system": True, + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + "ipServiceCategories": [{"value": "ANONYMIZER"}], + }, + ] + + service = self._build_service_with_raw_payload(zones_payload) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + ip_zone = service.network_zones["nzo-ip"] + assert ip_zone.type == "IP" + assert ip_zone.gateways == ["203.0.113.10/32"] + enhanced = service.network_zones["nzo-enhanced"] + assert enhanced.type == "DYNAMIC_V2" + assert enhanced.system is True + assert enhanced.ip_service_categories == ["ANONYMIZER"] + assert enhanced.asns == [] + assert enhanced.locations == [] + + def test_raw_fallback_handles_empty_payload(self): + service = self._build_service_with_raw_payload([]) + assert service.network_zones == {} + + def test_raw_fallback_handles_executor_error(self): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return (None, Exception("network down")) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = mock.AsyncMock() + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_execute_error(self): + service = self._build_service_with_raw_response( + None, + execute_error=Exception("timeout"), + ) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_decodes_bytes_response_body(self): + service = self._build_service_with_raw_payload( + [ + { + "id": "nzo-bytes", + "name": "Bytes", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ], + body_factory=lambda payload: json.dumps(payload).encode("utf-8"), + ) + + assert set(service.network_zones.keys()) == {"nzo-bytes"} + + def test_raw_fallback_handles_invalid_utf8_response_body(self): + service = self._build_service_with_raw_response(b"\xff") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_invalid_json_response_body(self): + service = self._build_service_with_raw_response("{") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_unexpected_payload_shape(self): + service = self._build_service_with_raw_payload({"id": "nzo-not-a-list"}) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_skips_non_dict_payload_items(self): + service = self._build_service_with_raw_payload( + [ + "not-a-zone", + { + "id": "nzo-valid", + "name": "Valid", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + }, + ] + ) + + assert set(service.network_zones.keys()) == {"nzo-valid"} + + def test_raw_fallback_paginates_via_link_header(self): + next_link = '; rel="next"' + page_1 = [ + { + "id": "nzo-1", + "name": "First", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + page_2 = [ + { + "id": "nzo-2", + "name": "Second", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + execute_calls = [] + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "")}, None) + + async def fake_raw_execute(request): + execute_calls.append(request) + if len(execute_calls) == 1: + return ( + SimpleNamespace(headers={"link": next_link}), + json.dumps(page_1), + None, + ) + return (SimpleNamespace(headers={}), json.dumps(page_2), None) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert len(execute_calls) == 2 + assert "after=cursor-2" in execute_calls[1]["url"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + +class Test_raw_zone_to_model: + def test_extracts_address_values_and_categories(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "name": "IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [ + {"type": "CIDR", "value": "203.0.113.0/24"}, + {"type": "RANGE", "value": "198.51.100.5-198.51.100.10"}, + ], + "proxies": [{"type": "CIDR", "value": "192.0.2.0/24"}], + "ipServiceCategories": [ + {"value": "ANONYMIZER"}, + {"value": "TOR_ANONYMIZER"}, + ], + } + ) + assert zone.gateways == [ + "203.0.113.0/24", + "198.51.100.5-198.51.100.10", + ] + assert zone.proxies == ["192.0.2.0/24"] + assert zone.ip_service_categories == ["ANONYMIZER", "TOR_ANONYMIZER"] + + def test_collapses_non_list_asns_and_locations_to_empty(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + } + ) + assert zone.asns == [] + assert zone.locations == [] + assert isinstance(zone, OktaNetworkZone) + + def test_extracts_ip_service_categories_from_raw_include_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": ["ALL_ANONYMIZERS"], + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_extracts_scalar_ip_service_category_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": {"value": "VPN_ANONYMIZER"}, + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["VPN_ANONYMIZER"] + + def test_ignores_none_address_entries_and_empty_condition_values(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "gateways": [None], + "ipServiceCategories": { + "include": None, + "exclude": [], + }, + } + ) + assert zone.gateways == [] + assert zone.ip_service_categories == [] + + def test_falls_back_name_to_id_when_missing(self): + zone = _raw_zone_to_model({"id": "nzo-1"}) + assert zone.id == "nzo-1" + assert zone.name == "nzo-1" + assert zone.status == "" + assert zone.system is False From 0ea2f6d67ee54a6e3e05f118fdb309dd4e18d117 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:11:54 +0200 Subject: [PATCH 021/129] feat(okta): add API token STIG checks (#11464) Co-authored-by: Daniel Barranquero --- .../providers/okta/authentication.mdx | 16 +- .../providers/okta/getting-started-okta.mdx | 18 +- prowler/CHANGELOG.md | 1 + prowler/providers/okta/okta_provider.py | 3 + .../okta/services/apitoken/__init__.py | 0 .../services/apitoken/api_token_client.py | 4 + .../services/apitoken/api_token_service.py | 327 ++++++++++ .../apitoken_not_super_admin/__init__.py | 0 .../apitoken_not_super_admin.metadata.json | 37 ++ .../apitoken_not_super_admin.py | 70 ++ .../__init__.py | 0 ...n_restricted_to_network_zone.metadata.json | 37 ++ .../apitoken_restricted_to_network_zone.py | 55 ++ .../okta/services/apitoken/lib/__init__.py | 0 .../apitoken/lib/api_token_helpers.py | 140 ++++ .../okta/lib/service/pagination_test.py | 147 +++++ tests/providers/okta/okta_fixtures.py | 6 + .../services/api_token/api_token_fixtures.py | 46 ++ .../api_token/api_token_service_test.py | 616 ++++++++++++++++++ .../apitoken_not_super_admin_test.py | 99 +++ ...pitoken_restricted_to_network_zone_test.py | 114 ++++ 21 files changed, 1724 insertions(+), 12 deletions(-) create mode 100644 prowler/providers/okta/services/apitoken/__init__.py create mode 100644 prowler/providers/okta/services/apitoken/api_token_client.py create mode 100644 prowler/providers/okta/services/apitoken/api_token_service.py create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py create mode 100644 prowler/providers/okta/services/apitoken/lib/__init__.py create mode 100644 prowler/providers/okta/services/apitoken/lib/api_token_helpers.py create mode 100644 tests/providers/okta/lib/service/pagination_test.py create mode 100644 tests/providers/okta/services/api_token/api_token_fixtures.py create mode 100644 tests/providers/okta/services/api_token/api_token_service_test.py create mode 100644 tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py create mode 100644 tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index 67eb72e153..74e8ef7805 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -36,6 +36,9 @@ The bundled checks require the following read-only scopes: - `okta.brands.read` - `okta.apps.read` - `okta.networkZones.read` +- `okta.apiTokens.read` +- `okta.roles.read` +- `okta.groups.read` - `okta.logStreams.read` - `okta.idps.read` @@ -46,7 +49,10 @@ Additional scopes will be needed as more services and checks are added. These ar | `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | | `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | -| `okta.networkZones.read` | Network Zone inventory and anonymized-proxy blocklist checks | +| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation | +| `okta.apiTokens.read` | API token metadata and token network conditions | +| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) | +| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) | | `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) | | `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) | @@ -130,7 +136,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 5. Grant the required OAuth scopes -On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) @@ -166,8 +172,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" uv run python prowler-cli.py okta ``` @@ -208,7 +214,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: -- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. ### Application-service checks return MANUAL on first-party apps diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 740cfbf729..32c08433b7 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -148,6 +148,7 @@ Prowler for Okta includes security checks across the following services: | **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | | **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | | **Network** | Network Zone blocklists for anonymized proxy sources | +| **API Token** | API token owner-role validation and Network Zone restrictions | | **User** | User lifecycle automations (inactivity-based deprovisioning) | | **System Log** | Log Stream configuration that off-loads audit records to a central SIEM | | **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility | @@ -162,25 +163,28 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, User, System Log, and Identity Provider services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, API Token, User, System Log, and Identity Provider services: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` - `okta.networkZones.read` +- `okta.apiTokens.read` +- `okta.roles.read` +- `okta.groups.read` - `okta.logStreams.read` - `okta.idps.read` -The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. +The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI): ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.logStreams.read okta.idps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index fab236e9e1..7893dd36b3 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) +- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 4356adbc57..e5c968e947 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -37,6 +37,9 @@ DEFAULT_SCOPES = [ "okta.brands.read", "okta.apps.read", "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", "okta.logStreams.read", "okta.idps.read", ] diff --git a/prowler/providers/okta/services/apitoken/__init__.py b/prowler/providers/okta/services/apitoken/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/api_token_client.py b/prowler/providers/okta/services/apitoken/api_token_client.py new file mode 100644 index 0000000000..fbe10d7c7f --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken + +api_token_client = ApiToken(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/apitoken/api_token_service.py b/prowler/providers/okta/services/apitoken/api_token_service.py new file mode 100644 index 0000000000..5fafd71c35 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_service.py @@ -0,0 +1,327 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "api_tokens": "okta.apiTokens.read", + "network_zones": "okta.networkZones.read", + "user_roles": "okta.roles.read", + # Needed to resolve admin roles inherited via group membership. + # `/api/v1/users/{id}/roles` returns only direct role assignments; + # group-inherited Super Admin is invisible without `okta.groups.read` + # to enumerate the user's groups. + "user_groups": "okta.groups.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _role_to_string(role) -> str: + """Pick the most specific role identifier from an SDK Role object. + + `list_assigned_roles_for_user` and `list_group_assigned_roles` return + `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper that holds + the real `StandardRole`/`CustomRole` on `.actual_instance`. Reading + `.type`/`.label` from the wrapper returns None and the role silently + disappears, so unwrap first. + """ + inner = getattr(role, "actual_instance", None) or role + return _value(getattr(inner, "type", None)) or _value(getattr(inner, "label", None)) + + +def _raw_value(item, key: str) -> str: + """Return a string value from an SDK model or raw dictionary.""" + if isinstance(item, dict): + return _value(item.get(key)) + return _value(getattr(item, key, None)) + + +class ApiToken(OktaService): + """Fetches Okta API token metadata, token owners' roles, and zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + # Per-resource caches keyed on the Okta resource id. API tokens + # commonly share owners (e.g. a service user holding multiple + # tokens) and admin groups frequently overlap across users, so we + # memoize the resolutions within a single service instance. + self._user_roles_cache: dict[str, list[str]] = {} + self._group_roles_cache: dict[str, list[str]] = {} + self.known_network_zone_ids: set[str] = ( + set() + if self.missing_scope["api_tokens"] or self.missing_scope["network_zones"] + else self._list_known_network_zone_ids() + ) + self.api_tokens: dict[str, OktaApiToken] = ( + {} if self.missing_scope["api_tokens"] else self._list_api_tokens() + ) + + def _list_api_tokens(self) -> dict[str, "OktaApiToken"]: + """List active API token metadata and owner roles.""" + logger.info("ApiToken - Listing Okta API tokens...") + try: + return self._run(self._fetch_api_tokens()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_api_tokens(self) -> dict[str, "OktaApiToken"]: + # `list_api_tokens` is non-paginated in the SDK (no `after` + # parameter); we inline the tuple unwrap rather than going + # through `paginate`. Same pattern application_service uses for + # `get_first_party_app_settings`. + result: dict[str, OktaApiToken] = {} + sdk_result = await self.client.list_api_tokens() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing API tokens: {err}") + return result + items = sdk_result[0] or [] + + for token in items: + token_id = _value(getattr(token, "id", None)) + user_id = _value(getattr(token, "user_id", None)) + roles = ( + await self._fetch_effective_user_role_types(user_id) if user_id else [] + ) + network = getattr(token, "network", None) + token_obj = OktaApiToken( + id=token_id, + name=_value(getattr(token, "name", None)) or token_id, + client_name=_value(getattr(token, "client_name", None)), + user_id=user_id, + network_connection=_value(getattr(network, "connection", None)), + network_includes=list(getattr(network, "include", None) or []), + network_excludes=list(getattr(network, "exclude", None) or []), + owner_roles=roles, + ) + result[token_obj.id] = token_obj + return result + + async def _fetch_effective_user_role_types(self, user_id: str) -> list[str]: + """Return direct + group-inherited admin role types for `user_id`. + + Okta's `/api/v1/users/{userId}/roles` (the SDK's + `list_assigned_roles_for_user`) only returns roles assigned + *directly* to the user. Roles inherited via group membership are + invisible to that endpoint — but they are how Okta normally + grants Super Admin (e.g. the org creator joins the default + "Okta Super Admins" group). Without resolving group-inherited + roles, the Super Admin check would falsely PASS for any token + whose owner gets admin via a group. + + Results are memoized per `user_id` so multiple tokens with the + same owner cost a single resolution. + """ + if user_id in self._user_roles_cache: + return self._user_roles_cache[user_id] + direct = await self._fetch_direct_user_role_types(user_id) + inherited = await self._fetch_group_inherited_role_types(user_id) + # Dedupe while preserving first-seen order (direct first, then + # inherited) so the status_extended reads from most-specific. + seen: set[str] = set() + combined: list[str] = [] + for role in (*direct, *inherited): + if role and role not in seen: + combined.append(role) + seen.add(role) + self._user_roles_cache[user_id] = combined + return combined + + async def _fetch_direct_user_role_types(self, user_id: str) -> list[str]: + """Return roles assigned directly to the user (no group inheritance).""" + if self.missing_scope["user_roles"]: + return [] + # `list_assigned_roles_for_user` is non-paginated in the SDK + # (no `after` parameter); inline the tuple unwrap. + sdk_result = await self.client.list_assigned_roles_for_user(user_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for token owner {user_id}: {err}") + return [] + items = sdk_result[0] or [] + roles = [_role_to_string(role) for role in items if _role_to_string(role)] + if roles or not items: + return roles + + # Belt-and-suspenders: when the SDK's typed parse returns items + # but every projection ends up empty (a discriminator surface we + # don't yet handle, a future schema change, …), fall back to the + # raw JSON. The `_role_to_string` unwrap above already covers the + # known `ListGroupAssignedRoles200ResponseInner` oneOf wrapper + # bug — this fallback exists for whatever the next SDK quirk is. + return await self._fetch_user_role_types_raw(user_id) + + async def _fetch_user_role_types_raw(self, user_id: str) -> list[str]: + """Return user role types from the raw response when typed models are empty. + + Uses the shared `get_json_paginated` helper so any `Link: next` + header the API returns is followed (role lists are typically + small, but the SDK doesn't paginate this endpoint at all so the + only correct way to drain it lives here). + """ + raw_items = await _raw_get_json_paginated( + self.client, + f"/api/v1/users/{user_id}/roles", + context=f"user roles for {user_id}", + ) + if raw_items is None: + return [] + roles = [ + _value(role.get("type")) or _value(role.get("label")) + for role in raw_items + if isinstance(role, dict) + ] + return [role for role in roles if role] + + async def _fetch_group_inherited_role_types(self, user_id: str) -> list[str]: + """Return roles inherited via the user's group memberships. + + Each group's role list is itself memoized — admin groups are + commonly shared across many users. + """ + if self.missing_scope["user_roles"] or self.missing_scope["user_groups"]: + return [] + # Defensive try/except: tenants we've seen in the wild return 403 + # on `/api/v1/users/{id}/groups` even when `okta.groups.read` is + # granted (admin-role on the service app gates the response + # separately). Treat any failure as "no inherited roles" so the + # caller still surfaces direct roles cleanly. + try: + sdk_result = await self.client.list_user_groups(user_id) + except Exception as error: + logger.error( + f"Error listing groups for token owner {user_id}: " + f"{error.__class__.__name__}: {error}" + ) + return [] + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing groups for token owner {user_id}: {err}") + return [] + groups = sdk_result[0] or [] + roles: list[str] = [] + for group in groups: + group_id = _value(getattr(group, "id", None)) + if not group_id: + continue + if group_id in self._group_roles_cache: + roles.extend(self._group_roles_cache[group_id]) + continue + # Per-group try/except: one group's parse or auth failure + # must not erase admin-role coverage for other groups. + try: + group_roles = await self._fetch_group_role_types(group_id) + except Exception as error: + logger.error( + f"Error listing roles for group {group_id} " + f"(owner={user_id}): {error.__class__.__name__}: {error}" + ) + group_roles = [] + self._group_roles_cache[group_id] = group_roles + roles.extend(group_roles) + return roles + + async def _fetch_group_role_types(self, group_id: str) -> list[str]: + """Return role types assigned to `group_id`.""" + sdk_result = await self.client.list_group_assigned_roles(group_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for group {group_id}: {err}") + return [] + items = sdk_result[0] or [] + return [_role_to_string(role) for role in items if _role_to_string(role)] + + def _list_known_network_zone_ids(self) -> set[str]: + """List known Network Zone ids and names for token condition validation.""" + logger.info("ApiToken - Listing Network Zones for token restrictions...") + try: + return self._run(self._fetch_known_network_zone_ids()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return set() + + async def _fetch_known_network_zone_ids(self) -> set[str]: + identifiers: set[str] = set() + items, err = await self._fetch_all_network_zones() + if err is not None: + logger.error(f"Error listing Network Zones for API token checks: {err}") + return identifiers + for zone in items: + zone_id = _raw_value(zone, "id") + zone_name = _raw_value(zone, "name") + if zone_id: + identifiers.add(zone_id) + if zone_name: + identifiers.add(zone_name) + return identifiers + + async def _fetch_all_network_zones(self) -> tuple[list, object]: + """Drain all Network Zone pages for API token reference validation. + + Catches the upstream Okta SDK ↔ Management API schema drift on + Enhanced Dynamic Zones (object-shaped pydantic model where the + API returns a JSON array) the same way `network_zone_service` + does. `(ValueError, ValidationError)` covers both discriminator + misses and model mismatches — matching the `user_service` + precedent. + """ + try: + return await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones " + "for API token validation — falling back to raw-JSON parse." + ) + return await self._fetch_all_network_zones_raw() + + async def _fetch_all_network_zones_raw(self) -> tuple[list, object]: + """Drain Network Zone pages via the shared raw-JSON helper.""" + items = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones for API token validation", + ) + if items is None: + return [], Exception("raw Network Zones fetch failed; see logs") + return items, None + + +class OktaApiToken(BaseModel): + """Normalized Okta API token metadata used by checks.""" + + id: str + name: str + client_name: str = "" + user_id: str = "" + network_connection: str = "" + network_includes: list[str] = Field(default_factory=list) + network_excludes: list[str] = Field(default_factory=list) + owner_roles: list[str] = Field(default_factory=list) diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json new file mode 100644 index 0000000000..d15c5b4680 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_not_super_admin", + "CheckTitle": "Okta API tokens are not owned by Super Admin users", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token ownership** should avoid Super Admin users because API tokens inherit the admin permissions of the user that created them.", + "Risk": "**Super Admin-owned API tokens** become high-impact secrets: if one is exposed, an attacker can perform broad organization administration with the token owner privileges.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/guides/roles", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Create a dedicated service account, assign only required admin roles, rotate the API token, and revoke Super Admin-owned tokens.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Use dedicated Okta service accounts** for API tokens and assign only the least-privilege admin roles required; rotate and revoke tokens created by Super Admin users.", + "Url": "https://hub.prowler.com/check/apitoken_not_super_admin" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py new file mode 100644 index 0000000000..0971af4aab --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py @@ -0,0 +1,70 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + missing_api_token_scope_finding, + missing_user_roles_scope_for_token_finding, + owner_has_super_admin, +) + + +class apitoken_not_super_admin(Check): + """Ensure Okta API tokens are not owned by Super Admin users.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token owner's assigned admin roles.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.roles.read", "okta.groups.read"], + ) + ] + + missing_user_roles_scope = api_token_client.missing_scope.get("user_roles") + # `okta.groups.read` is needed to resolve admin roles inherited via + # group membership. Without it we fall back to direct-only role + # assignments, which Okta returns for `/api/v1/users/{id}/roles` — + # commonly empty for trial accounts where Super Admin is granted + # through the default admin group. The finding stays evaluable but + # is flagged as best-effort so operators know to grant the scope. + missing_user_groups_scope = api_token_client.missing_scope.get("user_groups") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + if missing_user_roles_scope: + report = missing_user_roles_scope_for_token_finding( + self.metadata(), org_domain, token, missing_user_roles_scope + ) + elif owner_has_super_admin(token): + report.status = "FAIL" + report.status_extended = ( + f"API token {token.name} is owned by user {token.user_id} " + "with the Super Admin role. Use a dedicated service account " + "with least-privilege admin roles instead." + ) + else: + roles = ( + ", ".join(token.owner_roles) + if token.owner_roles + else "no admin roles returned" + ) + caveat = ( + " Group-inherited roles were not checked because the " + f"`{missing_user_groups_scope}` scope is missing — grant " + "it to detect Super Admin assigned via group membership." + if missing_user_groups_scope + else "" + ) + report.status = "PASS" + report.status_extended = ( + f"API token {token.name} owner {token.user_id} is not " + f"assigned Super Admin ({roles}).{caveat}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json new file mode 100644 index 0000000000..4a088a0c1c --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_restricted_to_network_zone", + "CheckTitle": "Okta API tokens are restricted to known Network Zones", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token network restrictions** should prevent token use from Any IP by tying each token to known Okta Network Zones.", + "Risk": "**API tokens allowed from Any IP** can be replayed from attacker-controlled infrastructure if the secret is exposed, removing an important network boundary.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken", + "https://help.okta.com/oie/en-us/content/topics/security/api.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > API > Tokens: edit each token Security section and select specific Network Zones instead of Any IP.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict every Okta API token to trusted IP-based Network Zones** and review token network conditions whenever service locations change.", + "Url": "https://hub.prowler.com/check/apitoken_restricted_to_network_zone" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py new file mode 100644 index 0000000000..04cfcf2df7 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + definite_network_zone_restriction_failure, + missing_api_token_scope_finding, + missing_network_zone_scope_for_token_finding, + network_zone_restriction_status, +) + + +class apitoken_restricted_to_network_zone(Check): + """Ensure Okta API tokens are restricted to known Network Zones.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token's network condition.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.networkZones.read"], + ) + ] + + missing_network_zone_scope = api_token_client.missing_scope.get("network_zones") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + if missing_network_zone_scope: + definite_failure = definite_network_zone_restriction_failure(token) + if definite_failure: + report = CheckReportOkta( + metadata=self.metadata(), + resource=token, + org_domain=org_domain, + ) + report.status, report.status_extended = definite_failure + else: + report = missing_network_zone_scope_for_token_finding( + self.metadata(), org_domain, token, missing_network_zone_scope + ) + else: + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + ( + report.status, + report.status_extended, + ) = network_zone_restriction_status( + token, api_token_client.known_network_zone_ids + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/lib/__init__.py b/prowler/providers/okta/services/apitoken/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py new file mode 100644 index 0000000000..3a871714ae --- /dev/null +++ b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py @@ -0,0 +1,140 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken + +ANYWHERE_CONNECTIONS = {"", "ANYWHERE", "ANY_IP"} +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def network_zone_restriction_status( + token: OktaApiToken, known_network_zone_ids: set[str] +) -> tuple[str, str]: + """Evaluate whether an API token is restricted to known Network Zones.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS: + return ( + "FAIL", + f"API token {token.name} can be used from any IP address. " + "Restrict the token to one or more known Okta Network Zones.", + ) + + if not token.network_includes: + return ( + "FAIL", + f"API token {token.name} does not allowlist a specific Okta " + "Network Zone. Excluded zones do not restrict the token to trusted " + "source networks.", + ) + + unknown_zones = [ + zone for zone in token.network_includes if zone not in known_network_zone_ids + ] + if unknown_zones: + return ( + "FAIL", + f"API token {token.name} references unknown Network Zone(s): " + f"{', '.join(unknown_zones)}.", + ) + + return ( + "PASS", + f"API token {token.name} is restricted to known Okta Network Zone(s): " + f"{', '.join(token.network_includes)}.", + ) + + +def definite_network_zone_restriction_failure( + token: OktaApiToken, +) -> tuple[str, str] | None: + """Return a definite network restriction failure that does not need zone lookup.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS or not token.network_includes: + return network_zone_restriction_status(token, set()) + return None + + +def owner_has_super_admin(token: OktaApiToken) -> bool: + """Return True when any token owner role is Super Admin.""" + for role in token.owner_roles: + normalized = role.strip().replace(" ", "_").upper() + if normalized in {"SUPER_ADMIN", "SUPER_ADMINISTRATOR"}: + return True + return False + + +def missing_api_token_scope_finding( + metadata, + org_domain: str, + scope: str, + additional_required: list[str] | None = None, +) -> CheckReportOkta: + """Build the MANUAL finding emitted when API tokens cannot be listed. + + `additional_required` lets the calling check name the secondary + scopes it also needs (e.g. `okta.roles.read` for the Super Admin + check, `okta.networkZones.read` for the zone-restriction check) so + the operator can grant everything in one go instead of re-running + once per missing scope. + """ + resource = OktaApiToken( + id="api-tokens-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + if additional_required: + extras = f" This check also requires {_format_scope_list(additional_required)}." + advice = ( + "Grant them on the service app's Okta API Scopes tab in the Okta " + "Admin Console, then re-run the check." + ) + else: + extras = "" + advice = _SCOPE_ADVICE + report.status_extended = ( + f"Could not retrieve Okta API token metadata: the Okta service app " + f"is missing the required `{scope}` API scope.{extras} {advice}" + ) + return report + + +def _format_scope_list(scopes: list[str]) -> str: + """Format a list of scope names as backticked, comma-joined text.""" + formatted = [f"`{scope}`" for scope in scopes] + if len(formatted) == 1: + return formatted[0] + if len(formatted) == 2: + return " and ".join(formatted) + return ", ".join(formatted[:-1]) + f", and {formatted[-1]}" + + +def missing_network_zone_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token zones cannot be validated.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not validate Network Zone restrictions for API token " + f"{token.name}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_user_roles_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token owner roles cannot be listed.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve admin roles for API token {token.name} owner " + f"{token.user_id}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/tests/providers/okta/lib/service/pagination_test.py b/tests/providers/okta/lib/service/pagination_test.py new file mode 100644 index 0000000000..a14baeb51e --- /dev/null +++ b/tests/providers/okta/lib/service/pagination_test.py @@ -0,0 +1,147 @@ +"""Tests for the shared Okta pagination helpers in +`prowler.providers.okta.lib.service.pagination`. + +Covers `next_after_cursor` (extracts the `after` query param from an +RFC 5988 `Link: rel="next"` header) and `paginate` (drains all pages +of an SDK list call by following the cursor). + +These tests were carved out of `network_zone_service_test.py` when its +local pagination helpers were replaced by the shared module — they now +cover code that six Okta services depend on. +""" + +import asyncio +from types import SimpleNamespace + +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor, + paginate, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +class Test_next_after_cursor: + """Behaviours previously covered in `network_zone_service_test.py` + under `Test_network_zone_pagination` — relocated here when the + local helper was replaced by the shared module. + """ + + def test_returns_none_when_response_is_none(self): + assert next_after_cursor(None) is None + + def test_returns_none_when_no_link_header(self): + assert next_after_cursor(_resp({})) is None + + def test_extracts_next_after_cursor(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "next-page" + + def test_reads_lowercase_link_header(self): + # aiohttp's `CIMultiDict` is case-insensitive in practice, but + # callers occasionally pass a dict, so we check both spellings. + link = '; rel="next"' + assert next_after_cursor(_resp({"link": link})) == "cursor-1" + + def test_next_link_without_after_query_returns_none(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) is None + + def test_no_next_segment_returns_none(self): + link = '; rel="self"' + assert next_after_cursor(_resp({"Link": link})) is None + + def test_url_decodes_after_cursor(self): + # `parse_qs` decodes percent-encoded values — opaque cursors with + # `=` or `+` must round-trip through callers that re-encode. + link = ( + "; " 'rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "cursor=abc+1" + + +class Test_paginate: + def test_returns_items_for_single_page_response(self): + async def fetch(_after): + return (["a", "b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + + def test_drains_multiple_pages(self): + link = '; rel="next"' + seen_cursors: list = [] + + async def fetch(after): + seen_cursors.append(after) + if after is None: + return (["a"], _resp({"link": link}), None) + return (["b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + assert seen_cursors == [None, "p2"] + + def test_returns_empty_when_first_page_is_empty(self): + async def fetch(_after): + return ([], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None + + def test_returns_empty_and_error_when_first_page_fails(self): + async def fetch(_after): + return ([], _resp({}), Exception("forbidden")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "forbidden" + + def test_returns_partial_items_when_subsequent_page_errors(self): + # Carved out of `network_zone_service_test.py`'s + # `test_pagination_returns_partial_items_when_second_page_errors`. + link = '; rel="next"' + + async def fetch(after): + if after is None: + return (["page-1"], _resp({"link": link}), None) + return ([], _resp({}), Exception("page failed")) + + items, err = _run(paginate(fetch)) + assert items == ["page-1"] + assert str(err) == "page failed" + + def test_accepts_early_error_two_tuple_shape(self): + # The Okta SDK returns `(items, err)` on request-build failures + # (no response) and `(items, resp, err)` on transport responses. + # `paginate` reads `result[-1]` for err so the 2-tuple shape is + # handled — verify explicitly. + async def fetch(_after): + return ([], Exception("create failed")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "create failed" + + def test_treats_none_items_as_empty_list(self): + async def fetch(_after): + return (None, _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 0ecb4a9eef..d1018a65eb 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -21,6 +21,9 @@ def set_mocked_okta_provider( "okta.brands.read", "okta.apps.read", "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", "okta.logStreams.read", "okta.idps.read", ], @@ -35,6 +38,9 @@ def set_mocked_okta_provider( "okta.brands.read", "okta.apps.read", "okta.networkZones.read", + "okta.apiTokens.read", + "okta.roles.read", + "okta.groups.read", "okta.logStreams.read", "okta.idps.read", ], diff --git a/tests/providers/okta/services/api_token/api_token_fixtures.py b/tests/providers/okta/services/api_token/api_token_fixtures.py new file mode 100644 index 0000000000..63a67c1605 --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_fixtures.py @@ -0,0 +1,46 @@ +from unittest import mock + +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_api_token_client( + tokens: dict = None, + known_network_zone_ids: set[str] = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.api_tokens = tokens or {} + client.known_network_zone_ids = known_network_zone_ids or {"nzo-corp"} + client.missing_scope = missing_scope or { + "api_tokens": None, + "network_zones": None, + "user_roles": None, + "user_groups": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def api_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + network_connection: str = "ZONE", + network_includes: list[str] = None, + network_excludes: list[str] = None, + owner_roles: list[str] = None, +): + return OktaApiToken( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network_connection=network_connection, + network_includes=( + network_includes if network_includes is not None else ["nzo-corp"] + ), + network_excludes=network_excludes or [], + owner_roles=owner_roles or ["READ_ONLY_ADMIN"], + ) diff --git a/tests/providers/okta/services/api_token/api_token_service_test.py b/tests/providers/okta/services/api_token/api_token_service_test.py new file mode 100644 index 0000000000..0958bdaaad --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_service_test.py @@ -0,0 +1,616 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + connection: str = "ZONE", + include: list[str] = None, + exclude: list[str] = None, +): + return SimpleNamespace( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network=SimpleNamespace( + connection=connection, + include=include if include is not None else ["nzo-corp"], + exclude=exclude or [], + ), + ) + + +def _sdk_role(role_type: str): + return SimpleNamespace(type=role_type, label=role_type.replace("_", " ").title()) + + +def _sdk_role_wrapped(role_type: str): + """Mimic `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper + holding the real StandardRole on `.actual_instance`. The Okta SDK + actually returns this shape; treating it like the bare role yields + `type=None, label=None` and the role silently vanishes from the + check. + """ + inner = _sdk_role(role_type) + return SimpleNamespace(actual_instance=inner, type=None, label=None) + + +def _sdk_zone(zone_id: str, name: str): + return SimpleNamespace(id=zone_id, name=name) + + +def _sdk_group(group_id: str): + return SimpleNamespace(id=group_id) + + +async def _empty_list(*_a, **_k): + return ([], _resp({}), None) + + +class Test_ApiToken_service: + def test_fetches_tokens_roles_and_known_network_zones(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = ApiToken(provider) + + assert set(service.api_tokens.keys()) == {token.id} + assert service.api_tokens[token.id].network_connection == "ZONE" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + assert service.known_network_zone_ids == {"nzo-corp", "Corporate"} + + def test_role_fetch_error_keeps_token_with_empty_roles(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_roles_error(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_roles_error + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [] + + def test_falls_back_to_raw_roles_when_sdk_role_is_empty(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([SimpleNamespace(type=None, label=None)], _resp({}), None) + + async def fake_create_request(*_a, **_k): + return ("raw-role-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-role-request" + return ( + _resp({}), + json.dumps( + [ + { + "id": "ra-super-admin", + "type": "SUPER_ADMIN", + "label": "Super Administrator", + } + ] + ), + None, + ) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked._list_assigned_roles_for_user_serialize.return_value = ( + "GET", + "/api/v1/users/00uabcdefg1234567890/roles", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_paginates_known_network_zones_for_token_validation(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-page-2"]) + next_link = '; rel="next"' + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **kwargs): + if kwargs.get("after") is None: + return ( + [_sdk_zone("nzo-page-1", "First")], + _resp({"link": next_link}), + None, + ) + return ([_sdk_zone("nzo-page-2", "Second")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == { + "nzo-page-1", + "First", + "nzo-page-2", + "Second", + } + + def test_falls_back_to_raw_network_zones_when_sdk_listing_fails(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-raw"]) + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + raise ValueError("EnhancedDynamicNetworkZone SDK deserialization failed") + + async def fake_create_request(*_a, **_k): + return ("raw-zones-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-zones-request" + return ( + _resp({}), + json.dumps([{"id": "nzo-raw", "name": "Raw Corporate"}]), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fake_list_network_zones + mocked._list_network_zones_serialize.return_value = ( + "GET", + "/api/v1/zones", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == {"nzo-raw", "Raw Corporate"} + + def test_returns_empty_on_token_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = failing + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens == {} + + def test_missing_api_token_scope_skips_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.networkZones.read", "okta.roles.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("API calls should not run without apiTokens scope") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fail_if_called + mocked.list_network_zones = fail_if_called + mocked.list_assigned_roles_for_user = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["api_tokens"] == "okta.apiTokens.read" + assert service.api_tokens == {} + assert service.known_network_zone_ids == set() + + def test_missing_network_zone_scope_skips_zone_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.roles.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.known_network_zone_ids == set() + assert set(service.api_tokens.keys()) == {token.id} + + def test_missing_role_scope_skips_role_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.networkZones.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_assigned_roles_for_user should not be called") + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fail_if_called + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_roles"] == "okta.roles.read" + assert service.api_tokens[token.id].owner_roles == [] + + +class Test_ApiToken_service_group_inherited_roles: + """Verifies effective-role resolution combines direct + group-inherited. + + Okta's `/api/v1/users/{userId}/roles` returns only directly-assigned + admin roles. Roles inherited via group membership — the common path + for Super Admin on trial tenants — are invisible to that endpoint. + The service must enumerate the user's groups and combine each + group's role assignments. + """ + + def test_group_inherited_super_admin_surfaces(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + assert user_id == token.user_id + return ( + [_sdk_group("0gp-admins"), _sdk_group("0gp-eng")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-admins": + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_direct_plus_group_roles_combined_and_deduped(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-1")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + # READ_ONLY_ADMIN already comes from the direct path; the + # dedupe should keep a single entry. SUPER_ADMIN is new. + return ( + [_sdk_role("READ_ONLY_ADMIN"), _sdk_role("SUPER_ADMIN")], + _resp({}), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "READ_ONLY_ADMIN", + "SUPER_ADMIN", + ] + + def test_role_resolution_cached_per_user_and_group(self): + provider = set_mocked_okta_provider() + token_a = _sdk_token(token_id="00Ttoken-a", user_id="00uowner-1") + token_b = _sdk_token(token_id="00Ttoken-b", user_id="00uowner-1") + token_c = _sdk_token(token_id="00Ttoken-c", user_id="00uowner-2") + + direct_calls: list[str] = [] + groups_calls: list[str] = [] + group_role_calls: list[str] = [] + + async def fake_list_api_tokens(*_a, **_k): + return ([token_a, token_b, token_c], _resp({}), None) + + async def fake_direct_roles(user_id, *_a, **_k): + direct_calls.append(user_id) + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + groups_calls.append(user_id) + return ([_sdk_group("0gp-shared")], _resp({}), None) + + async def fake_group_roles(group_id, *_a, **_k): + group_role_calls.append(group_id) + return ([_sdk_role("HELP_DESK_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + # Owner 00uowner-1 appears twice but is resolved once. + assert sorted(direct_calls) == ["00uowner-1", "00uowner-2"] + assert sorted(groups_calls) == ["00uowner-1", "00uowner-2"] + # Shared group resolved once even though both owners belong to it. + assert group_role_calls == ["0gp-shared"] + for token in (token_a, token_b, token_c): + assert service.api_tokens[token.id].owner_roles == ["HELP_DESK_ADMIN"] + + def test_missing_groups_scope_falls_back_to_direct_only(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=[ + "okta.apiTokens.read", + "okta.networkZones.read", + "okta.roles.read", + ], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError( + "list_user_groups must not be called without okta.groups.read" + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fail_if_called + mocked.list_group_assigned_roles = fail_if_called + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_groups"] == "okta.groups.read" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + + def test_wrapped_oneof_role_shape_is_unwrapped(self): + """Regression: the SDK returns each role as a oneOf wrapper with + the real StandardRole on `.actual_instance`. The previous + `_role_to_string` read `.type`/`.label` from the wrapper, got + None back, and produced an empty `owner_roles` — causing a + Super Admin token to silently PASS the check.""" + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role_wrapped("SUPER_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-extra")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + return ([_sdk_role_wrapped("APP_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "SUPER_ADMIN", + "APP_ADMIN", + ] + + def test_group_role_fetch_failure_does_not_drop_other_groups(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ( + [_sdk_group("0gp-broken"), _sdk_group("0gp-good")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-broken": + raise RuntimeError("upstream parse failure") + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] diff --git a/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py new file mode 100644 index 0000000000..6177d4b422 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_not_super_admin.apitoken_not_super_admin.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_not_super_admin.apitoken_not_super_admin import ( + apitoken_not_super_admin, + ) + + return apitoken_not_super_admin().execute() + + +class Test_apitoken_not_super_admin: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.roles.read" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_user_roles_scope_is_manual(self): + token = api_token(owner_roles=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_roles": "okta.roles.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.roles.read" in findings[0].status_extended + + def test_token_owner_without_super_admin_passes(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_owner_with_super_admin_fails(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + + def test_missing_groups_scope_adds_best_effort_caveat_on_pass(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Group-inherited roles were not checked" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_groups_scope_does_not_caveat_when_owner_is_super_admin(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + assert "Group-inherited" not in findings[0].status_extended diff --git a/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py new file mode 100644 index 0000000000..9da6f92939 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py @@ -0,0 +1,114 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone import ( + apitoken_restricted_to_network_zone, + ) + + return apitoken_restricted_to_network_zone().execute() + + +class Test_apitoken_restricted_to_network_zone: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_still_fails_anywhere_token(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_known_network_zone_passes(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_with_only_excluded_network_zone_fails(self): + token = api_token( + network_connection="ZONE", + network_includes=[], + network_excludes=["nzo-blocked"], + ) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-blocked"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not allowlist" in findings[0].status_extended + + def test_token_open_to_anywhere_fails(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_unknown_zone_fails(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-missing"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "unknown Network Zone" in findings[0].status_extended From e3013d99183ad17e5c30330ade4c925689f00700 Mon Sep 17 00:00:00 2001 From: StylusFrost <43682773+StylusFrost@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:47:22 +0200 Subject: [PATCH 022/129] feat(sdk): Dynamic provider loading and compliance framework (#10700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pedro Martín --- .github/workflows/sdk-tests.yml | 29 +- prowler/CHANGELOG.md | 6 +- prowler/__main__.py | 69 +- prowler/config/config.py | 75 +- prowler/lib/check/check.py | 60 +- prowler/lib/check/checks_loader.py | 11 +- prowler/lib/check/compliance_models.py | 44 +- prowler/lib/check/models.py | 49 +- prowler/lib/check/tool_wrapper.py | 62 + prowler/lib/check/utils.py | 107 +- prowler/lib/cli/parser.py | 59 +- prowler/lib/outputs/compliance/compliance.py | 34 +- .../lib/outputs/compliance/generic/generic.py | 82 +- prowler/lib/outputs/finding.py | 5 + prowler/lib/outputs/html/html.py | 12 +- prowler/lib/outputs/outputs.py | 58 +- prowler/lib/outputs/summary_table.py | 3 + prowler/lib/scan/scan.py | 13 +- prowler/providers/common/arguments.py | 47 +- prowler/providers/common/builtin.py | 29 + prowler/providers/common/models.py | 13 + prowler/providers/common/provider.py | 315 ++- tests/config/config_test.py | 52 + .../fixtures/config_namespaced_external.yaml | 8 + tests/lib/check/compliance_check_test.py | 4 +- tests/lib/check/models_test.py | 32 + tests/lib/check/tool_wrapper_test.py | 124 + .../compliance/generic/generic_aws_test.py | 45 + tests/lib/scan/scan_test.py | 8 +- tests/providers/external/__init__.py | 0 .../external/test_dynamic_provider_loading.py | 2054 +++++++++++++++++ 31 files changed, 3287 insertions(+), 222 deletions(-) create mode 100644 prowler/lib/check/tool_wrapper.py create mode 100644 prowler/providers/common/builtin.py create mode 100644 tests/config/fixtures/config_namespaced_external.yaml create mode 100644 tests/lib/check/tool_wrapper_test.py create mode 100644 tests/providers/external/__init__.py create mode 100644 tests/providers/external/test_dynamic_provider_loading.py diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index d0ef83c772..59316c0d95 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -540,7 +540,7 @@ jobs: with: flags: prowler-py${{ matrix.python-version }}-vercel files: ./vercel_coverage.xml - + # Scaleway Provider - name: Check if Scaleway files changed if: steps.check-changes.outputs.any_changed == 'true' @@ -588,7 +588,34 @@ jobs: with: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml + + # External Provider (dynamic loading) + - name: Check if External Provider files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-external + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + with: + files: | + ./prowler/providers/common/** + ./prowler/config/** + ./prowler/lib/** + ./tests/providers/external/** + ./uv.lock + - name: Run External Provider tests + if: steps.changed-external.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external + + - name: Upload External Provider coverage to Codecov + if: steps.changed-external.outputs.any_changed == 'true' + + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-external + files: ./external_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 7893dd36b3..8c976009f6 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -10,15 +10,13 @@ All notable changes to the **Prowler SDK** are documented in this file. - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) - Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) +- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) ---- - -## [5.29.3] (Prowler UNRELEASED) - ### 🐞 Fixed +- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) diff --git a/prowler/__main__.py b/prowler/__main__.py index ae1851bb17..6cbaf575d9 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -10,7 +10,6 @@ from colorama import Fore, Style from colorama import init as colorama_init from prowler.config.config import ( - EXTERNAL_TOOL_PROVIDERS, cloud_api_base_url, csv_file_suffix, get_available_compliance_frameworks, @@ -205,9 +204,10 @@ def prowler(): # We treat the compliance framework as another output format if compliance_framework: args.output_formats.extend(compliance_framework) - # If no input compliance framework, set all, unless a specific service or check is input - # Skip for IAC and LLM providers that don't use compliance frameworks - elif default_execution and provider not in ["iac", "llm"]: + # If no input compliance framework, set all, unless a specific service or check is input. + # Skip for tool-wrapper providers (iac, llm, image, and any external plug-in + # declaring `is_external_tool_provider = True`) — they don't use compliance frameworks. + elif default_execution and not Provider.is_tool_wrapper_provider(provider): args.output_formats.extend(get_available_compliance_frameworks(provider)) # Set Logger configuration @@ -245,7 +245,7 @@ def prowler(): universal_frameworks = {} # Skip compliance frameworks for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): bulk_compliance_frameworks = Compliance.get_bulk(provider) # Complete checks metadata with the compliance framework specification bulk_checks_metadata = update_checks_metadata_with_compliance( @@ -313,7 +313,7 @@ def prowler(): sys.exit() # Skip service and check loading for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): # Import custom checks from folder if checks_folder: custom_checks = parse_checks_from_folder(global_provider, checks_folder) @@ -436,6 +436,20 @@ def prowler(): output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + else: + # Dynamic fallback: any external/custom provider + try: + output_options = global_provider.get_output_options( + args, bulk_checks_metadata + ) + except NotImplementedError: + # No provider-specific OutputOptions: use the generic default so the + # run still produces output instead of aborting. + from prowler.providers.common.models import default_output_options + + output_options = default_output_options( + global_provider, args, bulk_checks_metadata + ) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: @@ -445,7 +459,7 @@ def prowler(): # Execute checks findings = [] - if provider in EXTERNAL_TOOL_PROVIDERS: + if Provider.is_tool_wrapper_provider(provider): # For external-tool providers, run the scan directly if provider == "llm": @@ -455,12 +469,19 @@ def prowler(): findings = global_provider.run_scan(streaming_callback=streaming_callback) else: - # Original behavior for IAC and Image - try: + if provider == "image": + try: + findings = global_provider.run() + except ImageBaseException as error: + logger.critical(f"{error}") + sys.exit(1) + else: + # IAC and external tool-wrapper providers registered via entry + # points. Unexpected failures propagate to the outer except + # Exception backstop further down in this file — keeping the + # branch free of an Image-specific catch that would otherwise + # mislead plug-in authors reading this code. findings = global_provider.run() - except ImageBaseException as error: - logger.critical(f"{error}") - sys.exit(1) # Note: External tool providers don't support granular progress tracking since # they run external tools as a black box and return all findings at once. # Progress tracking would just be 0% → 100%. @@ -1293,6 +1314,30 @@ def prowler(): ) generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + else: + # Dynamic fallback: any external/custom provider + try: + global_provider.generate_compliance_output( + finding_outputs, + bulk_compliance_frameworks, + input_compliance_frameworks, + output_options, + generated_outputs, + ) + except NotImplementedError: + # Last resort: generic compliance + for compliance_name in input_compliance_frameworks: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() # AWS Security Hub Integration if provider == "aws": diff --git a/prowler/config/config.py b/prowler/config/config.py index 4c76d98c1c..10e63c42df 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -1,3 +1,4 @@ +import importlib.metadata import os import pathlib from datetime import datetime, timezone @@ -85,13 +86,38 @@ class Provider(str, Enum): actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) +def _get_ep_compliance_dirs() -> dict: + """Discover compliance directories from entry points. Returns {provider: path}.""" + dirs = {} + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + try: + module = ep.load() + if hasattr(module, "__path__"): + dirs[ep.name] = module.__path__[0] + elif hasattr(module, "__file__"): + dirs[ep.name] = os.path.dirname(module.__file__) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return dirs + + def get_available_compliance_frameworks(provider=None): available_compliance_frameworks = [] - providers = [p.value for p in Provider] + # Built-in compliance + compliance_base = f"{actual_directory}/../compliance" if provider: providers = [provider] - for current_provider in providers: - compliance_dir = f"{actual_directory}/../compliance/{current_provider}" + else: + # Scan compliance directory for all provider subdirectories + providers = [] + if os.path.isdir(compliance_base): + for entry in os.scandir(compliance_base): + if entry.is_dir(): + providers.append(entry.name) + for prov in providers: + compliance_dir = f"{compliance_base}/{prov}" if not os.path.isdir(compliance_dir): continue with os.scandir(compliance_dir) as files: @@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append( file.name.removesuffix(".json") ) - # Also scan top-level compliance/ for multi-provider (universal) JSONs. + # Built-in multi-provider frameworks at top-level compliance/ directory. + # Placed before external entry points so built-ins win on name collisions. # When a specific provider was requested, only include the framework if it # declares support for that provider; otherwise include all universal frameworks. compliance_root = f"{actual_directory}/../compliance" @@ -117,6 +144,18 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) + # External compliance via entry points. + # Multi-provider support for external plug-ins is tracked in PROWLER-1444. + ep_dirs = _get_ep_compliance_dirs() + for prov, path in ep_dirs.items(): + if provider and prov != provider: + continue + if os.path.isdir(path): + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) return available_compliance_frameworks @@ -228,18 +267,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: with open(config_file_path, "r", encoding=encoding_format_utf_8) as f: config_file = yaml.safe_load(f) - # Not to introduce a breaking change, allow the old format config file without any provider keys - # and a new format with a key for each provider to include their configuration values within. - if any( - key in config_file - for key in ["aws", "gcp", "azure", "kubernetes", "m365"] + # Namespaced format: each provider has its own top-level key. + # Works for every built-in and every external plugin without a hardcoded list. + # Flat legacy format is AWS-only (historical, pre-multicloud). We identify it + # by the absence of nested-dict top-level values (namespaced files always + # have dict values; the legacy AWS format only has primitives/lists). + if ( + isinstance(config_file, dict) + and provider in config_file + and isinstance(config_file[provider], dict) ): - config = config_file.get(provider, {}) + config = config_file.get(provider, {}) or {} + elif ( + isinstance(config_file, dict) + and config_file + and provider == "aws" + and not any(isinstance(v, dict) for v in config_file.values()) + ): + config = config_file else: - config = config_file if config_file else {} - # Not to break Azure, K8s and GCP does not support or use the old config format - if provider in ["azure", "gcp", "kubernetes", "m365"]: - config = {} + config = {} return config diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 53b832290f..300520f589 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import json import os import re @@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger from prowler.lib.outputs.outputs import report from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes +from prowler.providers.common.builtin import is_builtin_provider from prowler.providers.common.models import Audit_Metadata @@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType: return lib +def _resolve_check_module( + provider_type: str, service: str, check_name: str +) -> ModuleType: + """Resolve and import a check module. + + Built-in wins on CheckID collision. Plug-ins are first-class extenders + (they can add new checks under new CheckIDs) but cannot override + existing built-ins — a security tool prefers fail-loud predictability + over silent overrides. CheckMetadata.get_bulk() applies the same + precedence on the metadata side (first-write-wins) and emits a warning + when a plug-in tries to override, so the user knows their plug-in + duplicate is being ignored and can rename it. + + Gates the built-in branch on `is_builtin_provider(provider_type)` — + calling `find_spec` on `prowler.providers.{provider_type}.services...` + directly would propagate `ModuleNotFoundError` for external providers + (their parent package `prowler.providers.{provider_type}` does not + exist) instead of returning None. The leaf helper encapsulates the + safe lookup, so external providers go straight to entry points. For + built-ins we still use `find_spec` to distinguish "check doesn't + exist" from "check exists but failed to import" (broken transitive + dep, etc.). + """ + # Built-in first — built-in wins on CheckID collision + if is_builtin_provider(provider_type): + builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}" + if importlib.util.find_spec(builtin_path) is not None: + return import_check(builtin_path) + + # Entry point lookup — only consulted when the built-in truly doesn't exist + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"): + if ep.name == check_name: + return importlib.import_module(ep.value) + + raise ModuleNotFoundError( + f"Check '{check_name}' not found for provider '{provider_type}'" + ) + + def run_fixer(check_findings: list) -> int: """ Run the fixer for the check if it exists and there are any FAIL findings @@ -525,9 +567,10 @@ def execute_checks( service = check_name.split("_")[0] try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -605,9 +648,10 @@ def execute_checks( ) try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -753,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif not is_builtin_provider(global_provider.type): + # External/custom provider — delegate identity args + is_finding_muted_args = global_provider.get_mutelist_finding_args() + for finding in check_findings: if global_provider.type == "cloudflare": is_finding_muted_args["account_id"] = finding.account_id diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 9ef672df6b..77840084ff 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -2,10 +2,10 @@ import sys from colorama import Fore, Style -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS from prowler.lib.check.check import parse_checks_from_file from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.models import CheckMetadata, Severity +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger @@ -26,8 +26,13 @@ def load_checks_to_execute( ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth across __main__, the CheckMetadata validators, + # check discovery and this loader, covering both built-in tool wrappers + # (iac/llm/image) and external plug-ins that declare + # `is_external_tool_provider = True` via the contract. + if is_tool_wrapper_provider(provider): return set() # Local subsets diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 82ac54e4d9..8cc588cc4c 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1,3 +1,4 @@ +import importlib.metadata import json import os import sys @@ -434,26 +435,57 @@ class Compliance(BaseModel): """Bulk load all compliance frameworks specification into a dict""" try: bulk_compliance_frameworks = {} + # Built-in compliance from prowler/compliance/{provider}/ available_compliance_framework_modules = list_compliance_modules() for compliance_framework in available_compliance_framework_modules: - if provider in compliance_framework.name: + # Match the provider segment exactly, not as a substring, so + # e.g. `cloud` does not capture `cloudflare`. + if compliance_framework.name.split(".")[-1] == provider: compliance_specification_dir_path = ( f"{compliance_framework.module_finder.path}/{provider}" ) - # for compliance_framework in available_compliance_framework_modules: for filename in os.listdir(compliance_specification_dir_path): file_path = os.path.join( compliance_specification_dir_path, filename ) - # Check if it is a file and ti size is greater than 0 if os.path.isfile(file_path) and os.stat(file_path).st_size > 0: - # Open Compliance file in JSON - # cis_v1.4_aws.json --> cis_v1.4_aws compliance_framework_name = filename.split(".json")[0] - # Store the compliance info bulk_compliance_frameworks[compliance_framework_name] = ( load_compliance_framework(file_path) ) + + # External compliance via entry points + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + if ep.name == provider: + try: + module = ep.load() + compliance_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + for filename in os.listdir(compliance_dir): + if filename.endswith(".json"): + file_path = os.path.join(compliance_dir, filename) + if ( + os.path.isfile(file_path) + and os.stat(file_path).st_size > 0 + ): + compliance_framework_name = filename.split(".json")[ + 0 + ] + if ( + compliance_framework_name + not in bulk_compliance_frameworks + ): + bulk_compliance_frameworks[ + compliance_framework_name + ] = load_compliance_framework(file_path) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index d97be78cbe..f9155c68f7 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set from pydantic.v1 import BaseModel, Field, ValidationError, validator from pydantic.v1.error_wrappers import ErrorWrapper -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider as ProviderABC # Valid ResourceGroup values as defined in the RFC VALID_RESOURCE_GROUPS = frozenset( @@ -259,7 +259,7 @@ class CheckMetadata(BaseModel): ) if ( value_lower not in VALID_CATEGORIES - and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS + and not ProviderABC.is_tool_wrapper_provider(values.get("Provider")) ): raise ValueError( f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}." @@ -288,7 +288,9 @@ class CheckMetadata(BaseModel): raise ValueError("ServiceName must be a non-empty string") check_id = values.get("CheckID") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): service_from_check_id = check_id.split("_")[0] if service_name != service_from_check_id: raise ValueError( @@ -304,7 +306,9 @@ class CheckMetadata(BaseModel): if not check_id: raise ValueError("CheckID must be a non-empty string") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): if "-" in check_id: raise ValueError( f"CheckID {check_id} contains a hyphen, which is not allowed" @@ -313,8 +317,9 @@ class CheckMetadata(BaseModel): return check_id @validator("CheckTitle", pre=True, always=True) + @classmethod def validate_check_title(cls, check_title, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(check_title) > 150: raise ValueError( f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters" @@ -326,14 +331,18 @@ class CheckMetadata(BaseModel): return check_title @validator("RelatedUrl", pre=True, always=True) + @classmethod def validate_related_url(cls, related_url, values): # noqa: F841 - if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if related_url and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): raise ValueError("RelatedUrl must be empty. This field is deprecated.") return related_url @validator("Remediation") + @classmethod def validate_recommendation_url(cls, remediation, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): url = remediation.Recommendation.Url if url and not url.startswith("https://hub.prowler.com/"): raise ValueError( @@ -346,7 +355,7 @@ class CheckMetadata(BaseModel): provider = values.get("Provider", "").lower() # Non-AWS providers must have an empty CheckType list - if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS: + if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider): if check_type: raise ValueError( f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'." @@ -371,8 +380,9 @@ class CheckMetadata(BaseModel): return check_type @validator("Description", pre=True, always=True) + @classmethod def validate_description(cls, description, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(description) > 400: raise ValueError( f"Description must not exceed 400 characters, got {len(description)} characters" @@ -380,8 +390,9 @@ class CheckMetadata(BaseModel): return description @validator("Risk", pre=True, always=True) + @classmethod def validate_risk(cls, risk, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(risk) > 400: raise ValueError( f"Risk must not exceed 400 characters, got {len(risk)} characters" @@ -433,6 +444,20 @@ class CheckMetadata(BaseModel): metadata_file = f"{check_path}/{check_name}.metadata.json" # Load metadata check_metadata = load_check_metadata(metadata_file) + # Built-in wins on CheckID collision. Plug-in entry points are + # appended after built-ins by `recover_checks_from_provider`, so + # a duplicate CheckID here means an entry-point check is trying + # to override a built-in. Ignore the override (the built-in + # metadata stays) and surface it via a warning — matching the + # precedence enforced by `_resolve_check_module`. + if check_metadata.CheckID in bulk_check_metadata: + logger.warning( + f"Plug-in check metadata '{check_metadata.CheckID}' " + f"(loaded from '{metadata_file}') is being IGNORED — " + f"a built-in with the same CheckID exists. To use your " + f"plug-in, register it under a different CheckID." + ) + continue bulk_check_metadata[check_metadata.CheckID] = check_metadata return bulk_check_metadata @@ -470,7 +495,7 @@ class CheckMetadata(BaseModel): # If the bulk checks metadata is not provided, get it if not bulk_checks_metadata: bulk_checks_metadata = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider_name in available_providers: bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name)) if provider: @@ -495,7 +520,7 @@ class CheckMetadata(BaseModel): # Loaded here, as it is not always needed if not bulk_compliance_frameworks: bulk_compliance_frameworks = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider in available_providers: bulk_compliance_frameworks = Compliance.get_bulk(provider=provider) checks_from_compliance_framework = ( diff --git a/prowler/lib/check/tool_wrapper.py b/prowler/lib/check/tool_wrapper.py new file mode 100644 index 0000000000..a1d606594e --- /dev/null +++ b/prowler/lib/check/tool_wrapper.py @@ -0,0 +1,62 @@ +"""Standalone helper for tool-wrapper provider detection. + +A provider is a "tool wrapper" if it delegates scanning to an external tool +(Trivy, promptfoo, etc.) instead of running checks/services through the +standard Prowler engine. This module is the single source of truth for that +classification across the codebase. + +Kept as a leaf module with no Prowler imports beyond the leaf +`external_tool_providers` so it can be referenced from `prowler.lib.check.*` +and `prowler.providers.common.provider` without forming an import cycle. +""" + +import importlib.metadata + +from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +from prowler.providers.common.builtin import is_builtin_provider + +# Module-level cache for entry-point classes consulted by this helper. +# Independent of `Provider._ep_providers` to keep this module leaf — the cost +# of a duplicate cache entry is negligible (one class object per external +# provider, loaded lazily on first lookup). +_ep_class_cache: dict = {} + + +def _load_ep_class(provider: str): + """Return the entry-point provider class for `provider`, or None. + + Caches the result in `_ep_class_cache`. Errors during entry-point loading + are swallowed (returning None) so a broken plug-in never crashes the + is-tool-wrapper check; it just falls through to "not a tool wrapper". + """ + if provider in _ep_class_cache: + return _ep_class_cache[provider] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == provider: + try: + cls = ep.load() + except Exception: + cls = None + _ep_class_cache[provider] = cls + return cls + _ep_class_cache[provider] = None + return None + + +def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for + iac/llm/image) with the `is_external_tool_provider` class attribute of + external plug-ins registered via entry points. This is the single source + of truth consulted by `__main__`, the `CheckMetadata` validators, the + check-loading utilities, and the checks loader. + """ + if provider in EXTERNAL_TOOL_PROVIDERS: + return True + # Built-in wins: short-circuit before ep.load() so a same-name plug-in + # cannot flip a built-in onto the tool-wrapper path or run its code. + if is_builtin_provider(provider): + return False + cls = _load_ep_class(provider) + return bool(cls and getattr(cls, "is_external_tool_provider", False)) diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index 0e4807078f..9c9a9c0523 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -1,9 +1,43 @@ import importlib +import importlib.metadata +import importlib.util +import os import sys from pkgutil import walk_packages -from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger +from prowler.providers.common.builtin import is_builtin_provider + + +def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]: + """Discover external checks registered via entry points for a provider. + + External plugins follow the same layout as built-ins: + `{plugin_root}.services.{service}.{check}.{check}` + + When `service` is provided, only entry points whose dotted path contains + `.services.{service}.` are included — mirroring how built-in discovery + filters by the `prowler.providers.{provider}.services.{service}` package. + + Uses find_spec to locate the check module without importing it, + avoiding service client initialization at discovery time. + """ + checks = [] + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"): + try: + if service and f".services.{service}." not in ep.value: + continue + + spec = importlib.util.find_spec(ep.value) + if spec and spec.origin: + check_path = os.path.dirname(spec.origin) + checks.append((ep.name, check_path)) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return checks def recover_checks_from_provider( @@ -15,29 +49,55 @@ def recover_checks_from_provider( Returns a list of tuples with the following format (check_name, check_path) """ try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS + # frozenset (built-ins) with the per-provider `is_external_tool_provider` + # class attribute (so external plug-ins opt in via the contract). + if is_tool_wrapper_provider(provider): return [] checks = [] - modules = list_modules(provider, service) - for module_name in modules: - # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" - check_module_name = module_name.name - # We need to exclude common shared libraries in services - if ( - check_module_name.count(".") == 6 - and ".lib." not in check_module_name - and (not check_module_name.endswith("_fixer") or include_fixers) - ): - check_path = module_name.module_finder.path - # Check name is the last part of the check_module_name - check_name = check_module_name.split(".")[-1] - check_info = (check_name, check_path) - checks.append(check_info) - except ModuleNotFoundError: - logger.critical(f"Service {service} was not found for the {provider} provider.") - sys.exit(1) + # Built-in checks from prowler.providers.{provider}.services. Gate + # the built-in branch on `is_builtin_provider(provider)` — calling + # `find_spec` directly on `prowler.providers.{provider}.services` + # would propagate `ModuleNotFoundError` when the parent package + # `prowler.providers.{provider}` does not exist (i.e. the provider + # is external), instead of returning None. The leaf helper + # encapsulates the safe lookup, so we only run the built-in + # discovery when the provider actually ships with the SDK; for + # external providers we go straight to entry points. + if is_builtin_provider(provider): + modules = list_modules(provider, service) + for module_name in modules: + # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" + check_module_name = module_name.name + # We need to exclude common shared libraries in services + if ( + check_module_name.count(".") == 6 + and ".lib." not in check_module_name + and (not check_module_name.endswith("_fixer") or include_fixers) + ): + check_path = module_name.module_finder.path + check_name = check_module_name.split(".")[-1] + check_info = (check_name, check_path) + checks.append(check_info) + + # External checks registered via entry points — always consulted, with + # optional service filter. Previously gated by `if not service:`, which + # prevented external providers from being usable with --service. + checks.extend(_recover_ep_checks(provider, service)) + + # A service was requested but nothing matched in either built-ins or + # entry points — surface this as a clear error instead of silently + # returning an empty list. + if service and not checks: + logger.critical( + f"Service '{service}' was not found for the '{provider}' provider " + f"(neither as a built-in nor via external entry points)." + ) + sys.exit(1) + except Exception as e: logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}") sys.exit(1) @@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set: Returns a set of checks from the given services """ try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — symmetric with + # `recover_checks_from_provider` above, using the same source of truth. + if is_tool_wrapper_provider(provider): return set() checks = set() diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 449c15a22c..8f05fc30cb 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -20,19 +20,61 @@ from prowler.providers.common.arguments import ( validate_provider_arguments, validate_sarif_usage, ) +from prowler.providers.common.provider import Provider class ProwlerArgumentParser: # Set the default parser def __init__(self): + # Discover any providers not in the hardcoded list below + # TODO - First step to support current providers and the new external provider implementation + known_providers = { + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "vercel", + "okta", + "scaleway", + "stackit", + } + all_providers = set(Provider.get_available_providers()) + new_providers = sorted(all_providers - known_providers) + + # Build extra strings for dynamically discovered providers + extra_providers_csv = "" + extra_providers_text = "" + if new_providers: + providers_help = Provider.get_providers_help_text() + extra_providers_csv = "," + ",".join(new_providers) + extra_lines = [] + for name in new_providers: + help_text = providers_help.get(name, "") + if help_text: + extra_lines.append(f" {name:<20}{help_text}") + if extra_lines: + extra_providers_text = "\n" + "\n".join(extra_lines) + # CLI Arguments self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...", - epilog=""" + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + epilog=f""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -52,13 +94,14 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider + vercel Vercel Provider{extra_providers_text} + Available components: dashboard Local dashboard To see the different available options on a specific component, run: - prowler {provider|dashboard} -h|--help + prowler {{provider|dashboard}} -h|--help Detailed documentation at https://docs.prowler.com """, @@ -117,8 +160,10 @@ Detailed documentation at https://docs.prowler.com and (sys.argv[1] not in ("-v", "--version")) ): # Since the provider is always the second argument, we are checking if - # a flag, starting by "-", is supplied - if "-" in sys.argv[1]: + # a flag is supplied. Use startswith("-") instead of "in" to avoid + # matching external provider names that contain hyphens + # (e.g. "local-acme-snowflake"). + if sys.argv[1].startswith("-"): sys.argv = self.__set_default_provider__(sys.argv) # Provider aliases mapping diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index 28611a68a1..65ad8af0b3 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -253,14 +253,32 @@ def display_compliance_table( compliance_overview, ) else: - get_generic_compliance_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) + # Try provider-specific table first, fall back to generic + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + handled = False + if provider is not None: + try: + handled = provider.display_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + except NotImplementedError: + handled = False + if not handled: + get_generic_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) except Exception as error: logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index c7217db5b3..b774f09577 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput): Returns: - None """ + + def compliance_row(requirement, attribute, finding=None): + # Read attribute fields defensively: GenericCompliance is the + # last-resort renderer for any framework, and provider-specific + # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal + # Section/SubSection/SubGroup/Service/Type/Comment fields. + return GenericComplianceModel( + Provider=(finding.provider if finding else compliance.Provider.lower()), + Description=compliance.Description, + AccountId=finding.account_uid if finding else "", + Region=finding.region if finding else "", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=getattr(attribute, "Section", None), + Requirements_Attributes_SubSection=getattr( + attribute, "SubSection", None + ), + Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None), + Requirements_Attributes_Service=getattr(attribute, "Service", None), + Requirements_Attributes_Type=getattr(attribute, "Type", None), + Requirements_Attributes_Comment=getattr(attribute, "Comment", None), + Status=finding.status if finding else "MANUAL", + StatusExtended=(finding.status_extended if finding else "Manual check"), + ResourceId=finding.resource_uid if finding else "manual_check", + ResourceName=finding.resource_name if finding else "Manual check", + CheckId=finding.check_id if finding else "manual", + Muted=finding.muted if finding else False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + for finding in findings: for requirement in compliance.Requirements: # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). if finding.check_id in requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, + self._data.append( + compliance_row(requirement, attribute, finding) ) - self._data.append(compliance_row) # Add manual requirements to the compliance output for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) + self._data.append(compliance_row(requirement, attribute)) diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 950460e4e3..9f772d17c4 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -517,6 +517,11 @@ class Finding(BaseModel): check_output, "fixed_version", "" ) + else: + # Dynamic fallback: any external/custom provider + provider_data = provider.get_finding_output_data(check_output) + output_data.update(provider_data) + # check_output Unique ID # TODO: move this to a function # TODO: in Azure, GCP and K8s there are findings without resource_name diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 33f7ef4934..52afd071ae 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1608,11 +1608,13 @@ class HTML(Output): # Azure_provider --> azure # Kubernetes_provider --> kubernetes - # Dynamically get the Provider quick inventory handler - provider_html_assessment_summary_function = ( - f"get_{provider.type}_assessment_summary" - ) - return getattr(HTML, provider_html_assessment_summary_function)(provider) + # Try static method first, fall back to provider method + method_name = f"get_{provider.type}_assessment_summary" + if hasattr(HTML, method_name): + return getattr(HTML, method_name)(provider) + else: + # Dynamic fallback: any external/custom provider + return provider.get_html_assessment_summary() except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index e4d935e3cf..a1f37a9dfc 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status from prowler.lib.outputs.finding import Finding -def stdout_report(finding, color, verbose, status, fix): +def stdout_report(finding, color, verbose, status, fix, provider=None): if finding.check_metadata.Provider == "aws": details = finding.region - if finding.check_metadata.Provider == "azure": + elif finding.check_metadata.Provider == "azure": details = finding.location - if finding.check_metadata.Provider == "gcp": + elif finding.check_metadata.Provider == "gcp": details = finding.location.lower() - if finding.check_metadata.Provider == "kubernetes": + elif finding.check_metadata.Provider == "kubernetes": details = finding.namespace.lower() - if finding.check_metadata.Provider == "github": + elif finding.check_metadata.Provider == "github": details = finding.owner - if finding.check_metadata.Provider == "m365": + elif finding.check_metadata.Provider == "m365": details = finding.location - if finding.check_metadata.Provider == "mongodbatlas": + elif finding.check_metadata.Provider == "mongodbatlas": details = finding.location - if finding.check_metadata.Provider == "nhn": + elif finding.check_metadata.Provider == "nhn": details = finding.location - if finding.check_metadata.Provider == "stackit": + elif finding.check_metadata.Provider == "stackit": details = finding.location - if finding.check_metadata.Provider == "llm": + elif finding.check_metadata.Provider == "llm": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "iac": + elif finding.check_metadata.Provider == "iac": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "oraclecloud": + elif finding.check_metadata.Provider == "oraclecloud": details = finding.region - if finding.check_metadata.Provider == "alibabacloud": + elif finding.check_metadata.Provider == "alibabacloud": details = finding.region - if finding.check_metadata.Provider == "openstack": + elif finding.check_metadata.Provider == "openstack": details = finding.region - if finding.check_metadata.Provider == "cloudflare": + elif finding.check_metadata.Provider == "cloudflare": details = finding.zone_name - if finding.check_metadata.Provider == "googleworkspace": + elif finding.check_metadata.Provider == "googleworkspace": details = finding.location - if finding.check_metadata.Provider == "vercel": + elif finding.check_metadata.Provider == "vercel": details = finding.region - if finding.check_metadata.Provider == "okta": + elif finding.check_metadata.Provider == "okta": details = finding.region - if finding.check_metadata.Provider == "scaleway": + elif finding.check_metadata.Provider == "scaleway": details = finding.region + else: + # Dynamic fallback: any external/custom provider + if provider is None: + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + details = provider.get_stdout_detail(finding) if (verbose or fix) and (not status or finding.status in status): if finding.muted: @@ -65,12 +72,15 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "verbose"): verbose = output_options.verbose if check_findings: - # TO-DO Generic Function if provider.type == "aws": check_findings.sort(key=lambda x: x.region) - - if provider.type == "azure": + elif provider.type == "azure": check_findings.sort(key=lambda x: x.subscription) + else: + # Dynamic fallback: any external/custom provider + sort_key = provider.get_finding_sort_key() + if sort_key and isinstance(sort_key, str): + check_findings.sort(key=lambda x: getattr(x, sort_key, "")) for finding in check_findings: # Print findings by stdout @@ -81,12 +91,16 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "fixer"): fixer = output_options.fixer color = set_report_color(finding.status, finding.muted) + # Pass the local `provider` through so the dynamic else inside + # `stdout_report` does not have to consult the global singleton + # — defeating the whole purpose of the new parameter. stdout_report( finding, color, verbose, status, fixer, + provider=provider, ) else: # No service resources in the whole account diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index e76728ccc8..43d4a547c1 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,9 @@ def display_summary_table( elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id + else: + # Dynamic fallback: any external/custom provider + entity_type, audited_entities = provider.get_summary_entity() # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 2ce7263e2b..4bef660d33 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -4,8 +4,8 @@ from types import SimpleNamespace from typing import Generator from prowler.lib.check.check import ( + _resolve_check_module, execute, - import_check, list_services, update_audit_metadata, ) @@ -426,9 +426,14 @@ class Scan: # Recover service from check name service = get_service_name_from_check_name(check_name) try: - # Import check module - check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) — + # delegates to `_resolve_check_module` so external + # providers registered via entry points are resolved + # correctly (their checks do not live under + # `prowler.providers.{type}.services...`). + lib = _resolve_check_module( + self._provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() diff --git a/prowler/providers/common/arguments.py b/prowler/providers/common/arguments.py index 9745bab2ca..9e23274a90 100644 --- a/prowler/providers/common/arguments.py +++ b/prowler/providers/common/arguments.py @@ -16,18 +16,41 @@ def init_providers_parser(self): # We need to call the arguments parser for each provider providers = Provider.get_available_providers() for provider in providers: - try: - getattr( - import_module( - f"{providers_path}.{provider}.{provider_arguments_lib_path}" - ), - init_provider_arguments_function, - )(self) - except Exception as error: - logger.critical( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - sys.exit(1) + # Discriminate built-in vs external upfront via find_spec, so an + # ImportError from a transitive dependency missing inside a built-in + # arguments module surfaces clearly instead of being silently + # re-routed to the entry-point path (which only has external providers). + if Provider.is_builtin(provider): + try: + getattr( + import_module( + f"{providers_path}.{provider}.{provider_arguments_lib_path}" + ), + init_provider_arguments_function, + )(self) + except ImportError as e: + logger.critical( + f"Failed to load arguments for built-in provider '{provider}'. " + f"Missing dependency: {e}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + sys.exit(1) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + sys.exit(1) + else: + # External provider — init_parser classmethod via entry point + cls = Provider._load_ep_provider(provider) + if cls and hasattr(cls, "init_parser"): + try: + cls.init_parser(self) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]: diff --git a/prowler/providers/common/builtin.py b/prowler/providers/common/builtin.py new file mode 100644 index 0000000000..d60b5483d9 --- /dev/null +++ b/prowler/providers/common/builtin.py @@ -0,0 +1,29 @@ +"""Leaf helper for built-in provider detection. + +Lives in its own module — with no imports back into `prowler.lib.check` — so +that callers in `prowler.lib.check.*` can ask "is this provider built-in?" +without creating an import cycle through `prowler.providers.common.provider` +(which transitively imports `prowler.config.config` and from there +`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`). + +Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate +to a leaf module is the canonical way to break the cycle in this codebase. +""" + +import importlib.util + + +def is_builtin_provider(provider: str) -> bool: + """Return True if the provider's own package ships with the SDK. + + Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)` + because `find_spec` propagates `ModuleNotFoundError` when a parent package + in the dotted path does not exist (instead of returning `None`). The + try/except is what makes the call safe for external providers, whose + package does not live under `prowler.providers.{provider}`. + """ + try: + spec = importlib.util.find_spec(f"prowler.providers.{provider}") + return spec is not None + except (ImportError, ValueError): + return False diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index ea70252f0a..120cc1a374 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -4,6 +4,7 @@ from os.path import isdir from pydantic.v1 import BaseModel +from prowler.config.config import output_file_timestamp from prowler.providers.common.provider import Provider @@ -69,3 +70,15 @@ class Connection: is_connected: bool = False error: Exception = None + + +def default_output_options(provider, arguments, bulk_checks_metadata): + """Generic OutputOptions fallback for external providers that do not + implement get_output_options, so the run still produces output instead of + aborting. Honors arguments.output_filename and otherwise derives a name + from the provider type.""" + output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) + output_options.output_filename = getattr(arguments, "output_filename", None) or ( + f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + return output_options diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index d11b015d54..8bc7567795 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import os import pkgutil import sys @@ -136,6 +138,106 @@ class Provider(ABC): """ return set() + # --- Dynamic provider contract methods (not @abstractmethod for incremental migration) --- + + _cli_help_text: str = "" + + @classmethod + def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider": + """Instantiate the provider from CLI arguments and return the instance. + + The caller wires the returned instance into the global provider slot + via Provider.set_global_provider(). Implementations that already call + set_global_provider(self) from __init__ are also supported — the call + site tolerates a None return in that case. + """ + raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()") + + def get_output_options(self, arguments, _bulk_checks_metadata): + """Create the provider-specific OutputOptions.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_output_options()" + ) + + def get_stdout_detail(self, _finding) -> str: + """Return the detail string for stdout reporting (region, location, etc.).""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_stdout_detail()" + ) + + def get_finding_sort_key(self) -> Optional[str]: + """Return the attribute name to sort findings by, or None for no sorting.""" + return None + + def get_summary_entity(self) -> tuple: + """Return (entity_type, audited_entities) for the summary table.""" + return (self.type, getattr(self.identity, "account_id", "")) + + def get_finding_output_data(self, _check_output) -> dict: + """Return provider-specific fields for Finding.generate_output().""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_finding_output_data()" + ) + + def get_html_assessment_summary(self) -> str: + """Return the HTML assessment summary card for this provider.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_html_assessment_summary()" + ) + + def generate_compliance_output( + self, + _findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + _output_options, + _generated_outputs, + ) -> None: + """Generate compliance CSV output for this provider's frameworks.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented generate_compliance_output()" + ) + + def get_mutelist_finding_args(self) -> dict: + """Return extra kwargs for mutelist.is_finding_muted() besides 'finding'. + + External providers must return a dict with the identity key their + Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``. + The ``finding`` kwarg is added automatically by the caller. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()" + ) + + def display_compliance_table( + self, + _findings: list, + _bulk_checks_metadata: dict, + _compliance_framework: str, + _output_filename: str, + _output_directory: str, + _compliance_overview: bool, + ) -> bool: + """Render a custom compliance table in the terminal. + + External providers can override this to display a detailed + compliance table (e.g., per-section breakdown). Return True + if the table was rendered, False to fall back to the generic table. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented display_compliance_table()" + ) + + # Class-level flag: True for providers that delegate scanning to an external + # tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and + # metadata validation. Subclasses override as `is_external_tool_provider = True`. + # Kept as a class attribute (not a property) so it can be read from the class + # without instantiation — the metadata validators in lib.check.models need to + # decide whether to relax validation before any provider instance exists. + is_external_tool_provider: bool = False + + # --- End dynamic provider contract methods --- + @staticmethod def get_excluded_regions_from_env() -> set: """Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable. @@ -159,20 +261,74 @@ class Provider(ABC): @staticmethod def init_global_provider(arguments: Namespace) -> None: try: - provider_class_path = ( - f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" - ) - provider_class_name = f"{arguments.provider.capitalize()}Provider" - provider_class = getattr( - import_module(provider_class_path), provider_class_name + # Discriminate built-in vs external upfront via find_spec, so an + # ImportError from a transitive dependency missing inside a + # built-in's own import chain surfaces clearly instead of being + # silently re-routed to the entry-point path. + provider_class = None + if Provider.is_builtin(arguments.provider): + # Built-in wins on provider-name collision. Plug-ins are + # first-class extenders (they can register new provider + # names) but cannot override existing built-ins — a security + # tool prefers fail-loud predictability over silent + # overrides. Surface the override so the user knows their + # plug-in is being ignored and can rename it. + # Match by name only — never ep.load() a shadowing plug-in. + if any( + ep.name == arguments.provider + for ep in importlib.metadata.entry_points(group="prowler.providers") + ): + logger.warning( + f"Plug-in provider '{arguments.provider}' registered " + f"via entry points is being IGNORED — a built-in with " + f"the same name exists. To use your plug-in, register " + f"it under a different name." + ) + provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" + provider_class_name = f"{arguments.provider.capitalize()}Provider" + try: + provider_class = getattr( + import_module(provider_class_path), provider_class_name + ) + except ImportError as e: + logger.critical( + f"Failed to load built-in provider '{arguments.provider}'. " + f"Missing dependency: {e}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + sys.exit(1) + except AttributeError: + # Module exists but doesn't define the expected class — + # treat as external and try entry points. + provider_class = Provider._load_ep_provider(arguments.provider) + else: + provider_class = Provider._load_ep_provider(arguments.provider) + + if provider_class is None: + raise ImportError( + f"Provider '{arguments.provider}' not found as built-in or entry point" + ) + + # Kept for downstream forks that may extend the dispatch below + # with their own custom built-in branches and reference this name. + # The upstream chain dispatches by `arguments.provider` directly. + provider_class_name = ( + f"{arguments.provider.capitalize()}Provider" # noqa: F841 ) fixer_config = load_and_validate_config_file( arguments.provider, arguments.fixer_config ) + # Dispatch by exact provider name (equality, not substring) so + # external plug-ins whose names contain a built-in substring + # (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed + # to the wrong built-in branch. Anything that doesn't match a + # built-in falls through to the dynamic else and uses the + # contract's `from_cli_args`. if not isinstance(Provider._global, provider_class): - if "aws" in provider_class_name.lower(): + if arguments.provider == "aws": excluded_regions = ( set(arguments.excluded_region) if getattr(arguments, "excluded_region", None) @@ -196,7 +352,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "azure" in provider_class_name.lower(): + elif arguments.provider == "azure": provider_class( az_cli_auth=arguments.az_cli_auth, sp_env_auth=arguments.sp_env_auth, @@ -209,7 +365,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "gcp" in provider_class_name.lower(): + elif arguments.provider == "gcp": provider_class( retries_max_attempts=arguments.gcp_retries_max_attempts, organization_id=arguments.organization_id, @@ -223,7 +379,7 @@ class Provider(ABC): fixer_config=fixer_config, skip_api_check=arguments.skip_api_check, ) - elif "kubernetes" in provider_class_name.lower(): + elif arguments.provider == "kubernetes": provider_class( kubeconfig_file=arguments.kubeconfig_file, context=arguments.context, @@ -233,7 +389,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "m365" in provider_class_name.lower(): + elif arguments.provider == "m365": provider_class( region=arguments.region, config_path=arguments.config_file, @@ -247,7 +403,7 @@ class Provider(ABC): init_modules=arguments.init_modules, fixer_config=fixer_config, ) - elif "nhn" in provider_class_name.lower(): + elif arguments.provider == "nhn": provider_class( username=arguments.nhn_username, password=arguments.nhn_password, @@ -256,7 +412,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "stackit" in provider_class_name.lower(): + elif arguments.provider == "stackit": provider_class( project_id=arguments.stackit_project_id, service_account_key_path=getattr( @@ -275,7 +431,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "github" in provider_class_name.lower(): + elif arguments.provider == "github": orgs = [] repos = [] @@ -307,13 +463,13 @@ class Provider(ABC): exclude_workflows=getattr(arguments, "exclude_workflows", []), fixer_config=fixer_config, ) - elif "googleworkspace" in provider_class_name.lower(): + elif arguments.provider == "googleworkspace": provider_class( config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "cloudflare" in provider_class_name.lower(): + elif arguments.provider == "cloudflare": provider_class( filter_zones=arguments.region, filter_accounts=arguments.account_id, @@ -321,7 +477,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "iac" in provider_class_name.lower(): + elif arguments.provider == "iac": provider_class( scan_path=arguments.scan_path, scan_repository_url=arguments.scan_repository_url, @@ -334,13 +490,13 @@ class Provider(ABC): oauth_app_token=arguments.oauth_app_token, provider_uid=arguments.provider_uid, ) - elif "llm" in provider_class_name.lower(): + elif arguments.provider == "llm": provider_class( max_concurrency=arguments.max_concurrency, config_path=arguments.config_file, fixer_config=fixer_config, ) - elif "image" in provider_class_name.lower(): + elif arguments.provider == "image": provider_class( images=arguments.images, image_list_file=arguments.image_list_file, @@ -358,7 +514,7 @@ class Provider(ABC): registry_insecure=arguments.registry_insecure, registry_list_images=arguments.registry_list_images, ) - elif "mongodbatlas" in provider_class_name.lower(): + elif arguments.provider == "mongodbatlas": provider_class( atlas_public_key=arguments.atlas_public_key, atlas_private_key=arguments.atlas_private_key, @@ -367,7 +523,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "oraclecloud" in provider_class_name.lower(): + elif arguments.provider == "oraclecloud": provider_class( oci_config_file=arguments.oci_config_file, profile=arguments.profile, @@ -378,7 +534,7 @@ class Provider(ABC): fixer_config=fixer_config, use_instance_principal=arguments.use_instance_principal, ) - elif "openstack" in provider_class_name.lower(): + elif arguments.provider == "openstack": provider_class( clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None), clouds_yaml_content=getattr( @@ -403,7 +559,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "alibabacloud" in provider_class_name.lower(): + elif arguments.provider == "alibabacloud": provider_class( role_arn=arguments.role_arn, role_session_name=arguments.role_session_name, @@ -415,14 +571,14 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "vercel" in provider_class_name.lower(): + elif arguments.provider == "vercel": provider_class( projects=getattr(arguments, "project", None), config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "okta" in provider_class_name.lower(): + elif arguments.provider == "okta": provider_class( okta_org_domain=getattr(arguments, "okta_org_domain", ""), okta_client_id=getattr(arguments, "okta_client_id", ""), @@ -435,7 +591,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "scaleway" in provider_class_name.lower(): + elif arguments.provider == "scaleway": # Credentials are read from the SCW_ACCESS_KEY / # SCW_SECRET_KEY env vars by the provider itself; there # are no credential CLI flags to avoid leaking secrets. @@ -447,6 +603,18 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + else: + # Dynamic fallback: any external/custom provider. + # Honor the from_cli_args type hint (-> Provider): if the + # implementation returns an instance, wire it as the global + # provider here. Implementations that call + # set_global_provider(self) from __init__ return None and + # remain supported (the condition below is a no-op for them). + provider_instance = provider_class.from_cli_args( + arguments, fixer_config + ) + if provider_instance is not None: + Provider.set_global_provider(provider_instance) except TypeError as error: logger.critical( @@ -459,17 +627,102 @@ class Provider(ABC): ) sys.exit(1) + # Cache for entry-point provider classes {name: class} + _ep_providers: dict = {} + @staticmethod def get_available_providers() -> list[str]: """get_available_providers returns a list of the available providers""" - providers = [] - # Dynamically import the package based on its string path + providers = set() + # Built-in providers from local package prowler_providers = importlib.import_module(providers_path) - # Iterate over all modules found in the prowler_providers package for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__): if provider != "common" and ispkg: - providers.append(provider) - return providers + providers.add(provider) + # External providers registered via entry points + for ep in importlib.metadata.entry_points(group="prowler.providers"): + providers.add(ep.name) + return sorted(providers) + + @staticmethod + def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`, + the leaf module that holds the actual logic. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + """ + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl + + return _impl(provider) + + @staticmethod + def is_builtin(provider: str) -> bool: + """Return True if the provider's own package is importable as a built-in. + + Delegates to `prowler.providers.common.builtin.is_builtin_provider`, + the leaf module that holds the actual check. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + Call sites in `prowler.lib.check.*` should import from the leaf + directly to avoid the import cycle through this module. + """ + from prowler.providers.common.builtin import is_builtin_provider as _impl + + return _impl(provider) + + @staticmethod + def _load_ep_provider(name: str): + """Load an external provider class from entry points, with cache. + + Caches both hits and misses so repeated lookups for unknown names do + not re-iterate entry_points(). Symmetric with + tool_wrapper._ep_class_cache. + """ + if name in Provider._ep_providers: + return Provider._ep_providers[name] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == name: + try: + cls = ep.load() + Provider._ep_providers[name] = cls + return cls + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + Provider._ep_providers[name] = None + return None + + @staticmethod + def get_providers_help_text() -> dict: + """Returns a dict of {provider_name: cli_help_text} for all available providers.""" + help_text = {} + for name in Provider.get_available_providers(): + try: + # Try built-in first + module_path = f"{providers_path}.{name}.{name}_provider" + module = import_module(module_path) + cls = None + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, Provider) + and attr is not Provider + ): + cls = attr + break + help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" + except ImportError: + # External provider — load via entry point + cls = Provider._load_ep_provider(name) + help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + help_text[name] = "" + return help_text @staticmethod def update_provider_config(audit_config: dict, variable: str, value: str): diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 1af6e32df2..2a7aecd330 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -471,6 +471,32 @@ class Test_Config: all_frameworks = get_available_compliance_frameworks() assert "csa_ccm_4.0" in all_frameworks + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins( + self, mock_dirs + ): + """Entry-point compliance frameworks that collide with a built-in + name must appear only once in the available frameworks list. + Built-in wins silently — same policy as the universal frameworks + loop and as Compliance.get_bulk.""" + import json + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # cis_2.0_aws ships as a built-in under prowler/compliance/aws/ + json_path = os.path.join(tmpdir, "cis_2.0_aws.json") + with open(json_path, "w") as f: + json.dump({"Framework": "CIS", "Provider": "aws"}, f) + + mock_dirs.return_value = {"aws": tmpdir} + + frameworks = get_available_compliance_frameworks("aws") + + assert frameworks.count("cis_2.0_aws") == 1, ( + f"Expected cis_2.0_aws to appear exactly once, got " + f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}" + ) + def test_load_and_validate_config_file_aws(self): path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) config_test_file = f"{path}/fixtures/config.yaml" @@ -508,6 +534,32 @@ class Test_Config: assert load_and_validate_config_file("azure", config_test_file) == {} assert load_and_validate_config_file("kubernetes", config_test_file) == {} + def test_load_and_validate_config_file_namespaced_non_listed_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it. + assert load_and_validate_config_file("github", config_test_file) == { + "token": "abc", + "org": "prowler-cloud", + } + + def test_load_and_validate_config_file_namespaced_external_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # External plug-in provider: namespaced format must unwrap its block. + assert load_and_validate_config_file("custom_plugin", config_test_file) == { + "setting": "value", + "nested": {"key": 42}, + } + + def test_load_and_validate_config_file_namespaced_missing_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # Provider with no section in a namespaced file must return empty config, + # not the full file (prevents cross-provider config leakage). + assert load_and_validate_config_file("aws", config_test_file) == {} + assert load_and_validate_config_file("gcp", config_test_file) == {} + def test_load_and_validate_config_file_invalid_config_file_path(self, caplog): provider = "aws" config_file_path = "invalid/path/to/fixer_config.yaml" diff --git a/tests/config/fixtures/config_namespaced_external.yaml b/tests/config/fixtures/config_namespaced_external.yaml new file mode 100644 index 0000000000..ec9f75c698 --- /dev/null +++ b/tests/config/fixtures/config_namespaced_external.yaml @@ -0,0 +1,8 @@ +# Namespaced config covering a non-listed built-in (github) and an external plugin. +github: + token: abc + org: prowler-cloud +custom_plugin: + setting: value + nested: + key: 42 diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index 73f93e3b38..631c134302 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -540,7 +540,9 @@ class TestCompliance: ): object = mock.Mock() object.path = "/path/to/compliance" - object.name = "framework1_aws" + # list_compliance_modules yields dotted module names; get_bulk matches + # the last segment exactly against the provider. + object.name = "prowler.compliance.aws" mock_list_modules.return_value = [object] mock_listdir.return_value = ["framework1_aws.json"] diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 71fd1f718b..f17e359441 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -95,6 +95,38 @@ class TestCheckMetada: "/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json" ) + @mock.patch("prowler.lib.check.models.logger") + @mock.patch("prowler.lib.check.models.load_check_metadata") + @mock.patch("prowler.lib.check.models.recover_checks_from_provider") + def test_get_bulk_builtin_wins_on_check_id_collision( + self, mock_recover_checks, mock_load_metadata, mock_logger + ): + """Regression guard: when an entry-point plug-in re-registers a + built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and + the plug-in is IGNORED. The override is surfaced via a warning so + the user knows their plug-in duplicate is being skipped and can + rename it. Matches the precedence in `_resolve_check_module`. See + PR #10700 review (HugoPBrito).""" + # Built-in first, plug-in last (matches recover_checks_from_provider order) + mock_recover_checks.return_value = [ + ("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"), + ("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"), + ] + + builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata] + + result = CheckMetadata.get_bulk(provider="aws") + + # Built-in wins (first-write-wins on CheckID), plug-in is ignored + assert result["accessanalyzer_enabled"] is builtin_metadata + # Override is surfaced via warning naming the plug-in metadata file + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args.args[0] + assert "accessanalyzer_enabled" in warning_msg + assert "/plugin/accessanalyzer_enabled" in warning_msg + @mock.patch("prowler.lib.check.models.load_check_metadata") @mock.patch("prowler.lib.check.models.recover_checks_from_provider") def test_list(self, mock_recover_checks, mock_load_metadata): diff --git a/tests/lib/check/tool_wrapper_test.py b/tests/lib/check/tool_wrapper_test.py new file mode 100644 index 0000000000..5f4f0c7f88 --- /dev/null +++ b/tests/lib/check/tool_wrapper_test.py @@ -0,0 +1,124 @@ +"""Unit tests for prowler.lib.check.tool_wrapper. + +Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates +to it). Tests the frozenset fast path, the entry-point fallback for external +plug-ins, the broken-plug-in path, the no-match path, and the module-level +cache. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_ep_class_cache(): + """Reset the leaf module's cache between tests so they stay independent.""" + from prowler.lib.check import tool_wrapper + + tool_wrapper._ep_class_cache.clear() + yield + tool_wrapper._ep_class_cache.clear() + + +def _make_entry_point(name, cls): + """Create a mock entry point whose `load()` returns `cls`.""" + ep = MagicMock() + ep.name = name + ep.load.return_value = cls + return ep + + +class TestIsToolWrapperProvider: + """is_tool_wrapper_provider: frozenset + entry-point fallback.""" + + @pytest.mark.parametrize("name", ["iac", "llm", "image"]) + def test_returns_true_for_builtin_tool_wrappers(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is True + + @pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"]) + def test_returns_false_for_regular_builtins(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_true_for_external_plugin_with_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)] + + assert is_tool_wrapper_provider("custom_wrapper") is True + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_external_plugin_without_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=False) + mock_eps.return_value = [_make_entry_point("vanilla_external", cls)] + + assert is_tool_wrapper_provider("vanilla_external") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + mock_eps.return_value = [] + + assert is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps): + """A plug-in registered under a built-in's name cannot flip the + built-in onto the tool-wrapper path, and its module is never loaded.""" + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True)) + mock_eps.return_value = [malicious] + + # `aws` is a built-in, so classification short-circuits to False... + assert is_tool_wrapper_provider("aws") is False + # ...and the shadowing plug-in's code is never executed via ep.load(). + malicious.load.assert_not_called() + + +class TestLoadEpClass: + """_load_ep_class: cache, broken plug-ins, no-match.""" + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_caches_result_across_calls(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("cached_one", cls)] + + first = _load_ep_class("cached_one") + second = _load_ep_class("cached_one") + + assert first is cls + assert second is cls + # entry_points consulted only on the first call + assert mock_eps.call_count == 1 + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_for_broken_plugin(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + broken_ep = MagicMock() + broken_ep.name = "broken" + broken_ep.load.side_effect = ImportError("plug-in is broken") + mock_eps.return_value = [broken_ep] + + assert _load_ep_class("broken") is None + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_when_no_entry_point_matches(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock() + mock_eps.return_value = [_make_entry_point("other_provider", cls)] + + assert _load_ep_class("missing_provider") is None diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index 335d1860ea..335a9ad17d 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import ( Compliance, Compliance_Requirement, Generic_Compliance_Requirement_Attribute, + ISO27001_2013_Requirement_Attribute, ) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -198,3 +199,47 @@ class TestAWSGenericCompliance: ), f"Expected 1 row driven by framework JSON, got {len(rows)}" assert rows[0].Requirements_Id == "req_in_framework" assert rows[0].CheckId == "service_check_in_framework" + + def test_transform_tolerates_framework_specific_attribute_schema(self): + """GenericCompliance is the documented last-resort renderer, so it must not + crash on a framework whose attribute schema lacks the universal fields + (Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares + none of them; missing fields must render as None instead of raising + AttributeError and dropping the whole CSV.""" + framework_name = "ISO27001-2013-External" + compliance = Compliance( + Framework=framework_name, + Name=framework_name, + Provider="external", + Version="", + Description="Framework shipping a provider-specific attribute schema", + Requirements=[ + Compliance_Requirement( + Id="A.5.1.1", + Description="Policies for information security", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="Information security policies", + Objetive_ID="A.5.1", + Objetive_Name="Management direction", + Check_Summary="Policy is defined", + ) + ], + Checks=["service_test_check_id"], + ) + ], + ) + + findings = [generate_finding_output(check_id="service_test_check_id")] + + output = GenericCompliance(findings, compliance) + + rows = [row for row in output.data if row.Status != "MANUAL"] + assert len(rows) == 1 + assert rows[0].Requirements_Id == "A.5.1.1" + assert rows[0].Requirements_Attributes_Section is None + assert rows[0].Requirements_Attributes_SubSection is None + assert rows[0].Requirements_Attributes_SubGroup is None + assert rows[0].Requirements_Attributes_Service is None + assert rows[0].Requirements_Attributes_Type is None + assert rows[0].Requirements_Attributes_Comment is None diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index b0fe82b7b2..8037668b10 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -51,7 +51,7 @@ def mock_provider(): def mock_execute(): with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec: findings = [finding] - mock_exec.side_effect = lambda *args, **kwargs: findings + mock_exec.side_effect = lambda *_args, **_kwargs: findings yield mock_exec @@ -264,10 +264,10 @@ class TestScan: @patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance") @patch("prowler.lib.scan.scan.Compliance.get_bulk") @patch("prowler.lib.scan.scan.CheckMetadata.get_bulk") - @patch("prowler.lib.scan.scan.import_check") + @patch("prowler.lib.scan.scan._resolve_check_module") def test_scan( self, - mock_import_check, + mock_resolve_check_module, mock_get_bulk, mock_compliance_get_bulk, mock_update_checks_metadata, @@ -285,7 +285,7 @@ class TestScan: mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled" mock_check_instance.Categories = [] - mock_import_check.return_value = MagicMock( + mock_resolve_check_module.return_value = MagicMock( accessanalyzer_enabled=mock_check_class ) diff --git a/tests/providers/external/__init__.py b/tests/providers/external/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py new file mode 100644 index 0000000000..e60720e32e --- /dev/null +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -0,0 +1,2054 @@ +""" +Tests for dynamic provider loading via entry points. + +Covers: provider discovery, check discovery, check execution, +CLI argument registration, compliance frameworks, parser integration, +and all dispatch fallbacks for external providers. +""" + +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.provider import Provider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entry_point(name, value, group): + """Create a mock entry point.""" + ep = MagicMock() + ep.name = name + ep.value = value + ep.group = group + return ep + + +class FakeExternalProvider(Provider): + """Minimal Provider subclass for testing the dynamic contract.""" + + _type = "fakeexternal" + _cli_help_text = "Fake External Provider" + + def __init__(self): + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-host-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + cls() + + def get_output_options(self, _arguments, _bulk_checks_metadata): + return MagicMock(output_directory="/tmp", output_filename="fake") + + def get_stdout_detail(self, finding): + return "fake-detail" + + def get_finding_sort_key(self): + return "region" + + def get_summary_entity(self): + return ("Fake Host", "fake-host-1") + + def get_finding_output_data(self, check_output): + return { + "auth_method": "fake", + "account_uid": "fake-account", + "account_name": "fake", + "resource_name": "fake-resource", + "resource_uid": "fake-uid", + "region": "local", + } + + def get_mutelist_finding_args(self): + return {"host_id": self.identity.host_id} + + def display_compliance_table( + self, + findings, + _bulk_checks_metadata, + _compliance_framework, + _output_filename, + output_directory, # referenced via name elsewhere in tests + _compliance_overview, + ): + return True + + def get_html_assessment_summary(self): + return "
Fake Assessment
" + + def generate_compliance_output( + self, + findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + output_options, + generated_outputs, + ): + generated_outputs["compliance"].append("fake-compliance-output") + + @classmethod + def init_parser(cls, parser_instance): + pass + + +class FakeToolWrapperProvider(Provider): + """External provider that declares itself a tool wrapper.""" + + _type = "faketoolwrapper" + is_external_tool_provider = True + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +class FakePureContractProvider(Provider): + """External provider that honors the from_cli_args type hint literally: + returns an instance without calling Provider.set_global_provider() from + __init__. Used to verify the call site wires the returned instance.""" + + _type = "fakepure" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-pure-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + # Literal contract: return the instance, no side-effect in __init__. + return cls() + + +class FakeProviderNoHelpText(Provider): + """Provider without _cli_help_text.""" + + _type = "nohelptext" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_ep_cache(): + """Clear the entry point provider cache before each test.""" + Provider._ep_providers = {} + yield + Provider._ep_providers = {} + + +@pytest.fixture +def fake_provider(): + """Create and register a FakeExternalProvider.""" + p = FakeExternalProvider() + yield p + Provider._global = None + + +# =========================================================================== +# 1. Provider Discovery & Loading +# =========================================================================== + + +class TestProviderDiscovery: + """Tests 1-7: get_available_providers, _load_ep_provider, get_providers_help_text.""" + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_merges_builtin_and_entrypoint(self, mock_ep): + """Test 1: get_available_providers returns both built-in and entry point providers.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + # Built-in providers from actual prowler package + assert "aws" in providers + # External provider from entry point + assert "fakeexternal" in providers + assert "common" not in providers + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_deduplicates(self, mock_ep): + """Test 2: Same provider name in built-in and entry point appears once.""" + # "aws" exists as built-in AND as entry point + mock_ep.return_value = [ + _make_entry_point("aws", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + assert providers.count("aws") == 1 + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_loads_class(self, mock_ep): + """Test 3: _load_ep_provider loads the class from entry point.""" + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider._load_ep_provider("fakeexternal") + + assert cls is FakeExternalProvider + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_returns_none_for_unknown(self, mock_ep): + """Test 4: _load_ep_provider returns None for unknown provider.""" + mock_ep.return_value = [] + + cls = Provider._load_ep_provider("nonexistent") + + assert cls is None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_result(self, mock_ep): + """Test 5: _load_ep_provider caches the loaded class.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls1 = Provider._load_ep_provider("fakeexternal") + cls2 = Provider._load_ep_provider("fakeexternal") + + assert cls1 is cls2 + # load() should only be called once due to caching + mock_ep.return_value[0].load.assert_called_once() + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_misses(self, mock_ep): + """A miss (unknown provider) must also be cached so repeated lookups + do not re-iterate entry_points(). Aligns with tool_wrapper._ep_class_cache, + which already caches None on miss.""" + mock_ep.return_value = [] + + first = Provider._load_ep_provider("nonexistent") + second = Provider._load_ep_provider("nonexistent") + + assert first is None + assert second is None + # entry_points() should only be called once across the two lookups + mock_ep.assert_called_once() + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_reads_cli_help_text( + self, mock_providers, mock_load + ): + """Test 6: get_providers_help_text reads _cli_help_text from entry point provider.""" + mock_providers.return_value = ["fakeexternal"] + mock_load.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_empty_without_cli_help_text( + self, mock_providers, mock_load + ): + """Test 7: get_providers_help_text returns empty string without _cli_help_text.""" + mock_providers.return_value = ["nohelptext"] + mock_load.return_value = FakeProviderNoHelpText + + help_text = Provider.get_providers_help_text() + + assert help_text["nohelptext"] == "" + + +class TestIsToolWrapperProvider: + """Tests for Provider.is_tool_wrapper_provider — the helper that combines the + built-in EXTERNAL_TOOL_PROVIDERS frozenset with the is_external_tool_provider + class attribute of entry-point providers.""" + + def test_returns_true_for_builtin_tool_wrappers(self): + # iac/llm/image are in the EXTERNAL_TOOL_PROVIDERS frozenset (fast path) + assert Provider.is_tool_wrapper_provider("iac") is True + assert Provider.is_tool_wrapper_provider("llm") is True + assert Provider.is_tool_wrapper_provider("image") is True + + def test_returns_false_for_regular_builtin_providers(self): + # Regular built-ins must not be classified as tool wrappers + assert Provider.is_tool_wrapper_provider("aws") is False + assert Provider.is_tool_wrapper_provider("gcp") is False + assert Provider.is_tool_wrapper_provider("github") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_true_for_external_provider_declaring_flag(self, mock_ep): + # External plugin explicitly declares is_external_tool_provider = True + mock_ep.return_value = [ + _make_entry_point("faketoolwrapper", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeToolWrapperProvider + + assert Provider.is_tool_wrapper_provider("faketoolwrapper") is True + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_external_provider_without_flag(self, mock_ep): + # External plugin without the flag (default False) is treated as regular + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + assert Provider.is_tool_wrapper_provider("fakeexternal") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_ep): + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_none_provider(self, mock_ep): + # Pydantic validators may pass None when values.get("Provider") is missing + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider(None) is False + + +class TestIsBuiltinProvider: + """Tests for Provider.is_builtin — the helper that discriminates built-in + providers from external ones before attempting the import, so transitive + dependency failures in built-ins don't get silently re-routed to entry points.""" + + def test_returns_true_for_builtin_provider(self): + assert Provider.is_builtin("aws") is True + assert Provider.is_builtin("github") is True + + def test_returns_false_for_unknown_provider(self): + assert Provider.is_builtin("nonexistent_xyz") is False + + @patch("prowler.providers.common.provider.importlib.util.find_spec") + def test_returns_false_when_find_spec_raises(self, mock_find_spec): + # Certain namespace package edge cases raise ValueError/ImportError — + # helper should swallow and return False rather than propagate. + mock_find_spec.side_effect = ValueError("namespace package edge case") + + assert Provider.is_builtin("some_provider") is False + + +class TestInitProvidersParserBuiltinDependencyFailure: + """Tests the critical behavior fix: when a built-in provider's arguments + module exists but its imports fail (e.g. boto3 not installed), we must + fail loudly with a clear message — not silently fall through to entry + points as if the provider were external.""" + + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + from prowler.providers.common.arguments import init_providers_parser + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + parser = MagicMock() + parser._providers = ["aws"] + + with ( + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ), + pytest.raises(SystemExit), + ): + init_providers_parser(parser) + + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + def test_external_provider_does_not_touch_builtin_path( + self, mock_load_ep, mock_is_builtin + ): + from prowler.providers.common.arguments import init_providers_parser + + mock_is_builtin.return_value = False + ext_cls = MagicMock() + ext_cls.init_parser = MagicMock() + mock_load_ep.return_value = ext_cls + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["fakeexternal"], + ): + init_providers_parser(parser) + + ext_cls.init_parser.assert_called_once_with(parser) + + +class TestInitGlobalProviderBuiltinDependencyFailure: + """Same contract as TestInitProvidersParserBuiltinDependencyFailure but + for the provider class import path in init_global_provider.""" + + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + Provider._global = None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_handles_load_exception(self, mock_ep): + """_load_ep_provider returns None when ep.load() raises.""" + ep = _make_entry_point("broken", "pkg:Cls", "prowler.providers") + ep.load.side_effect = Exception("Import failed") + mock_ep.return_value = [ep] + + cls = Provider._load_ep_provider("broken") + + assert cls is None + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_builtin_path(self, mock_providers, mock_import): + """get_providers_help_text reads _cli_help_text from a built-in provider module.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + + mock_cls = type( + "FakeBuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakeBuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_generic_exception( + self, mock_providers, mock_import + ): + """get_providers_help_text handles generic exceptions with empty string.""" + mock_providers.return_value = ["broken"] + mock_import.side_effect = RuntimeError("Unexpected error") + + help_text = Provider.get_providers_help_text() + + assert help_text["broken"] == "" + + +# =========================================================================== +# 2. Provider Initialization +# =========================================================================== + + +class TestProviderInitialization: + """Tests 8-9: init_global_provider fallback to entry point.""" + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_fallback_to_entry_point( + self, mock_import, mock_load_ep, mock_config + ): + """Test 8: init_global_provider falls back to entry point when built-in fails.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakeexternal", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_exits_for_unknown_provider( + self, mock_import, mock_load_ep, mock_config + ): + """Test 9: init_global_provider exits when provider not found anywhere.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = None + mock_config.return_value = {} + + args = Namespace( + provider="nonexistent", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_wires_instance_returned_by_from_cli_args( + self, mock_import, mock_load_ep, mock_config + ): + """A provider that implements from_cli_args as a pure function (returns + the instance without calling set_global_provider from __init__) is + correctly wired as the global provider by init_global_provider.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + @pytest.mark.parametrize( + "plugin_name", + [ + "awsx", + "aws_lite", + "azure_gov", + "gcp_org", + "github_enterprise", + "iac_v2", + ], + ) + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_external_with_builtin_substring_uses_from_cli_args( + self, mock_import, mock_load_ep, mock_config, plugin_name + ): + """Regression guard for the substring footgun in the dispatch chain. + + An external plug-in whose name contains a built-in substring + (e.g. `awsx`, `aws_lite`, `azure_gov`, `gcp_org`, `github_enterprise`, + `iac_v2`) MUST be routed to the dynamic else and instantiated via + `from_cli_args` — not silently captured by the built-in branch whose + name happens to be a substring of the plug-in name. See PR #10700 + review. + """ + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + # Namespace deliberately omits the kwargs of any built-in branch + # (no `aws_retries_max_attempts`, `az_cli_auth`, `personal_access_token`, + # etc.). If equality dispatch is broken and the plug-in is misrouted to + # a built-in branch, attribute access will raise and the global never + # gets wired. + args = Namespace( + provider=plugin_name, + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_warns_when_plugin_shadowed_by_builtin( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, mock_logger + ): + """Regression guard: when a plug-in registers a provider name that + collides with a built-in, the BUILT-IN wins and a warning is emitted + naming the shadowed plug-in. Shadow detection matches by entry-point + name only — the plug-in is never `ep.load()`-ed just to warn, so its + module code cannot run during a built-in run. See PR #10700 review + (HugoPBrito, Alan-TheGentleman). + """ + # Simulate a built-in `aws` that exists, AND a plug-in registered + # under the same `aws` name via entry points. + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + mock_import.return_value = MagicMock( + AwsProvider=MagicMock(side_effect=lambda **_kw: None) + ) + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + try: + Provider.init_global_provider(args) + except BaseException: + # The AwsProvider mock is fake and the dispatch may sys.exit on + # the simulated failure; we only care about the warning emitted + # before the dispatch happens. + pass + finally: + Provider._global = None + + # Warning was emitted naming the shadowed plug-in + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "Plug-in provider 'aws'" in call.args[0] + ] + assert warning_msgs, "expected a warning about the shadowed plug-in 'aws'" + assert "IGNORED" in warning_msgs[0] + # Shadow detected by name only — plug-in code never executed to warn + shadow_ep.load.assert_not_called() + + +# =========================================================================== +# 3. Check Discovery +# =========================================================================== + + +class TestCheckDiscovery: + """Tests 10-14: _recover_ep_checks, recover_checks_from_provider.""" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_discovers_checks(self, mock_spec, mock_ep): + """Test 10: _recover_ep_checks discovers checks from entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("my_check", "pkg.checks.my_check", "prowler.checks.fake"), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/path/to/pkg/checks/my_check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("fake") + + assert len(checks) == 1 + assert checks[0][0] == "my_check" + assert checks[0][1] == "/path/to/pkg/checks" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + def test_recover_ep_checks_empty_without_entry_points(self, mock_ep): + """Test 11: _recover_ep_checks returns empty list with no entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [] + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_handles_broken_entry_point(self, mock_spec, mock_ep): + """Test 12: _recover_ep_checks handles failed entry points gracefully.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("broken_check", "pkg.broken", "prowler.checks.fake"), + ] + mock_spec.side_effect = Exception("Module not found") + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_handles_external_provider_without_services( + self, mock_find_spec, mock_ep_checks + ): + """Test 13: recover_checks_from_provider doesn't crash for external providers. + + With find_spec returning None (built-in package doesn't exist), discovery + falls through to entry points cleanly — no ModuleNotFoundError catch + needed. + """ + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("ext_check", "/path/to/check")] + + checks = recover_checks_from_provider("fakeexternal") + + assert len(checks) == 1 + assert checks[0][0] == "ext_check" + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_combines_builtin_and_entry_points( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Test 14: recover_checks_from_provider combines built-in and entry point checks.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in package exists + + # Simulate a built-in module + builtin_module = MagicMock() + builtin_module.name = "prowler.providers.aws.services.ec2.check_a.check_a" + builtin_module.module_finder.path = "/builtin/path" + mock_list_modules.return_value = [builtin_module] + + mock_ep_checks.return_value = [("check_b", "/external/path")] + + checks = recover_checks_from_provider("aws") + + check_names = [c[0] for c in checks] + assert "check_a" in check_names + assert "check_b" in check_names + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_filters_by_service(self, mock_spec, mock_ep): + """Service filter keeps only entry points whose dotted path includes + `.services.{service}.` — mirroring the built-in package filter.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_has_no_root_user" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_without_service_returns_all(self, mock_spec, mock_ep): + """Without a service filter, all entry points for the provider are returned.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop") + + assert len(checks) == 2 + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_external_provider_with_service( + self, mock_find_spec, mock_ep_checks + ): + """External provider with --service: built-in package doesn't exist, + but entry points are still consulted and return the requested service's + checks. No premature sys.exit.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("container_check", "/ext/path")] + + checks = recover_checks_from_provider("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_check" + mock_ep_checks.assert_called_once_with("dockerdesktop", "container") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_unknown_service_fails_cleanly( + self, mock_find_spec, mock_ep_checks + ): + """A typo or unknown service (not in built-ins nor in entry points) + fails with a clear error message, not a silent empty result.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None + mock_ep_checks.return_value = [] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="typo_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_builtin_with_new_external_service( + self, mock_find_spec, mock_ep_checks + ): + """Built-in provider with a new service added via entry points: + the built-in package for that specific service doesn't exist (find_spec + returns None), but entry points pick it up. The gate `if not service:` + that previously skipped entry points when --service was passed is removed.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # built-in for new_aws_service doesn't exist + mock_ep_checks.return_value = [("new_check", "/ext/path")] + + checks = recover_checks_from_provider("aws", service="new_aws_service") + + assert len(checks) == 1 + assert checks[0][0] == "new_check" + mock_ep_checks.assert_called_once_with("aws", "new_aws_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_surfaces_error_when_builtin_service_import_fails( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Regression guard: when a built-in service's package exists but one + of its modules fails to import (e.g. a broken transitive dependency), + the error must surface via the global exception handler — not be + silently swallowed and replaced by an entry-point plug-in that happens + to share a name. See PR #10700 review (HugoPBrito).""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in service exists + mock_list_modules.side_effect = ImportError("missing transitive dep: foo") + + # Even if a plug-in registers checks for the same service, it must NOT + # silently take over — the import error wins. + mock_ep_checks.return_value = [("evil_check", "/evil/path")] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="ec2") + + +# =========================================================================== +# 4. Check Execution +# =========================================================================== + + +class TestCheckExecution: + """Tests 15-17: _resolve_check_module.""" + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_first(self, mock_import, mock_find_spec): + """Test 15: _resolve_check_module resolves built-in checks first.""" + from prowler.lib.check.check import _resolve_check_module + + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_find_spec.return_value = MagicMock() # built-in package exists + + result = _resolve_check_module("aws", "ec2", "my_check") + + assert result is mock_module + mock_import.assert_called_once_with( + "prowler.providers.aws.services.ec2.my_check.my_check" + ) + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_fallback_to_entry_point( + self, mock_import_check, mock_find_spec + ): + """Test 16: _resolve_check_module falls back to entry point when built-in is absent.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None # built-in does not exist + + mock_ext_module = MagicMock() + ep = _make_entry_point( + "my_check", "ext_pkg.checks.my_check", "prowler.checks.fake" + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module", return_value=mock_ext_module) as mock_imp, + ): + result = _resolve_check_module("fake", "svc", "my_check") + + assert result is mock_ext_module + mock_imp.assert_called_with("ext_pkg.checks.my_check") + mock_import_check.assert_not_called() + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_wins_over_entry_point( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when both a built-in and an entry-point check + exist with the same CheckID, the BUILT-IN wins. Plug-ins extend + Prowler with new checks but cannot silently override existing + built-ins — a security tool prefers fail-loud predictability over + permissive overrides. CheckMetadata.get_bulk applies the same + precedence (first-write-wins) and emits a warning. See PR #10700 + review (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in exists + builtin_module = MagicMock() + mock_import_check.return_value = builtin_module + + # Plug-in registers same CheckID — must NOT take precedence + ep = _make_entry_point( + "ec2_instance_public_ip", + "plug_pkg.checks.ec2_instance_public_ip", + "prowler.checks.aws", + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module") as mock_imp, + ): + result = _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + assert result is builtin_module + mock_import_check.assert_called_once_with( + "prowler.providers.aws.services.ec2.ec2_instance_public_ip.ec2_instance_public_ip" + ) + # Plug-in must NOT be loaded when a built-in with the same CheckID exists + mock_imp.assert_not_called() + + @patch("prowler.lib.check.check.importlib.metadata.entry_points") + @patch("prowler.lib.check.check.importlib.util.find_spec") + def test_resolve_check_module_raises_when_not_found(self, mock_find_spec, mock_ep): + """Test 17: _resolve_check_module raises ModuleNotFoundError when both fail.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None + mock_ep.return_value = [] + + with pytest.raises(ModuleNotFoundError, match="not found"): + _resolve_check_module("fake", "svc", "nonexistent_check") + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_surfaces_error_when_builtin_import_fails( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when no plug-in entry-point overrides the + check, a built-in whose module exists but fails to import (e.g. + broken transitive dependency) MUST surface the real error instead + of being silently treated as 'not found'. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in module exists + mock_import_check.side_effect = ImportError("missing transitive dep: foo") + + # No plug-in override — the built-in's import failure must propagate + with patch("importlib.metadata.entry_points", return_value=[]): + with pytest.raises(ImportError, match="missing transitive dep"): + _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + +# =========================================================================== +# 5. CLI Arguments +# =========================================================================== + + +class TestCLIArguments: + """Tests 18-19: init_providers_parser fallback.""" + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_fallback_to_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 18: init_providers_parser falls back to cls.init_parser for external providers.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in arguments module") + mock_load_ep.return_value = FakeExternalProvider + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_no_crash_without_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 19: init_providers_parser doesn't crash if provider has no init_parser.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["nohelptext"] + mock_import.side_effect = ImportError("No built-in") + # FakeProviderNoHelpText has no init_parser + mock_load_ep.return_value = FakeProviderNoHelpText + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_handles_init_parser_exception( + self, mock_import, mock_providers, mock_load_ep + ): + """init_providers_parser handles exception when init_parser raises.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in") + + broken_cls = MagicMock() + broken_cls.init_parser.side_effect = RuntimeError("Parser init failed") + mock_load_ep.return_value = broken_cls + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + +# =========================================================================== +# 6. Compliance +# =========================================================================== + + +class TestCompliance: + """Tests 20-23: compliance discovery and loading.""" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_discovers_dirs(self, mock_ep): + """Test 20: _get_ep_compliance_dirs discovers compliance directories.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock() + mock_module.__path__ = ["/path/to/compliance"] + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["fakeexternal"] == "/path/to/compliance" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_file_fallback(self, mock_ep): + """_get_ep_compliance_dirs uses __file__ when module has no __path__.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = "/path/to/compliance/__init__.py" + del mock_module.__path__ + ep = _make_entry_point("ext", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["ext"] == "/path/to/compliance" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_handles_load_exception(self, mock_ep): + """_get_ep_compliance_dirs handles ep.load() exception gracefully.""" + from prowler.config.config import _get_ep_compliance_dirs + + ep = _make_entry_point("broken", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs == {} + + @patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_includes_external(self, mock_dirs): + """Test 21: get_available_compliance_frameworks includes external compliance.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + # Create a temp dir with a compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "custom_1.0_ext.json") + with open(json_path, "w") as f: + json.dump({"Framework": "Custom", "Provider": "ext"}, f) + + mock_dirs.return_value = {"ext": tmpdir} + + frameworks = get_available_compliance_frameworks("ext") + + assert "custom_1.0_ext" in frameworks + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep): + """Test 22: Compliance.get_bulk loads external compliance JSON.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + # Create a valid compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom Framework", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test framework", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_1.0_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_1.0_fakeexternal" in bulk + assert bulk["custom_1.0_fakeexternal"].Framework == "Custom" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep): + """Compliance.get_bulk uses __file__ when external module has no __path__.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom File Fallback", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_file_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = os.path.join(tmpdir, "__init__.py") + del mock_module.__path__ + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_file_fakeexternal" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_handles_external_exception( + self, mock_list_modules, mock_ep + ): + """Compliance.get_bulk handles exception when loading external compliance.""" + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert bulk == {} + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_builtin_wins_on_duplicate( + self, mock_list_modules, mock_ep + ): + """Test 23: Compliance.get_bulk built-in wins on duplicate framework names.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + mock_ep.return_value = [] + + # If both exist with same key, built-in (loaded first) should win + # Since we have no built-in modules mocked, just verify external loads + # The actual dedup logic: `if name not in bulk_compliance_frameworks` + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "CIS", + "Name": "CIS Test", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, "dup_framework.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "dup_framework" in bulk + + @pytest.mark.parametrize( + "provider, framework_segments", + [ + # `cloud` is a substring of THREE built-in modules at once. + ("cloud", ["alibabacloud", "cloudflare", "oraclecloud"]), + ("git", ["github"]), + ("work", ["googleworkspace"]), + ("open", ["openstack"]), + ], + ) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_matches_provider_segment_exactly( + self, mock_list_modules, mock_ep, provider, framework_segments + ): + """Regression: a provider whose name is a substring of one or more + framework modules must NOT load them. The old `provider in name` + check captured overlapping built-ins (e.g. `cloud` matched + alibabacloud, cloudflare and oraclecloud). See PR #10700 review + (Alan-TheGentleman). + """ + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_ep.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + # The substring path the old code would have read from. + os.mkdir(os.path.join(tmpdir, provider)) + json_data = { + "Framework": "Custom", + "Name": f"Should not load for '{provider}'", + "Version": "1.0", + "Provider": provider, + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, provider, "wrong.json"), "w") as f: + json.dump(json_data, f) + + modules = [] + for segment in framework_segments: + module = MagicMock() + module.name = f"prowler.compliance.{segment}" + module.module_finder.path = tmpdir + modules.append(module) + mock_list_modules.return_value = modules + + bulk = Compliance.get_bulk(provider) + + # Exact-segment match: the provider is not any of these modules. + assert "wrong" not in bulk + assert bulk == {} + + +# =========================================================================== +# 7. Parser +# =========================================================================== + + +class TestParser: + """Tests 24-27: parser dynamic discovery.""" + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_discovers_new_providers(self, mock_providers, mock_help): + """Test 24: Parser discovers providers not in known_providers.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + + assert "fakeexternal" in parser.parser.format_usage() + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_appends_to_epilog_with_help_text(self, mock_providers, mock_help): + """Test 25: Parser appends new providers to epilog with _cli_help_text.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + assert "fakeexternal" in epilog + assert "Fake External Provider" in epilog + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_skips_epilog_entry_without_help_text( + self, mock_providers, mock_help + ): + """Test 26: Parser doesn't add epilog entry if _cli_help_text is empty.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "nohelptext", + ] + mock_help.return_value = {"nohelptext": ""} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + # Should appear in usage/csv but NOT in the descriptive epilog listing + assert "nohelptext" in parser.parser.format_usage() + # No line with "nohelptext Something" in epilog + epilog_lines = [ + line.strip() for line in epilog.splitlines() if "nohelptext" in line + ] + assert len(epilog_lines) == 0 or all( + "nohelptext" in line and line.strip() == "nohelptext" or "{" in line + for line in epilog_lines + ) + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_does_not_duplicate_known_providers(self, mock_providers, mock_help): + """Test 27: Parser doesn't duplicate providers already in the known list.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + # No new providers + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + ] + mock_help.return_value = {} + + parser = ProwlerArgumentParser() + usage = parser.parser.format_usage() + + # aws should appear exactly once in usage + assert usage.count("aws") == 1 + + +# =========================================================================== +# 8. Dispatch Fallbacks +# =========================================================================== + + +class TestDispatchFallbacks: + """Tests 28-34: all else clause fallbacks for external providers.""" + + def test_stdout_report_calls_get_stdout_detail(self, fake_provider): + """Test 28: stdout_report else clause calls provider.get_stdout_detail.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report( + finding, "\033[31m", True, ["FAIL"], False, provider=fake_provider + ) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_stdout_report_resolves_provider_when_none(self, fake_provider): + """stdout_report resolves provider via get_global_provider when not passed.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report(finding, "\033[31m", True, ["FAIL"], False) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_report_propagates_provider_to_stdout_report(self): + """Regression guard: report() must pass its local `provider` through to + stdout_report so the dynamic else does not fall back to the global + singleton. With Provider._global cleared, the call chain still has to + work for an external provider — proving the argument is being used + instead of `Provider.get_global_provider()`. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.outputs.outputs import report + from prowler.providers.common.provider import Provider + + local_provider = FakeExternalProvider.__new__(FakeExternalProvider) + + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.region = "x" + finding.check_metadata.Provider = "fakeexternal" + finding.status_extended = "test" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = [] + output_options.fixer = False + + # Clear the global singleton so any unintended fallback would crash. + previous_global = Provider._global + Provider._global = None + try: + with patch("builtins.print") as mock_print: + report([finding], local_provider, output_options) + + # report() prints the finding line plus an empty separator when + # verbose is set; we only care that the finding was rendered using + # the local provider's `get_stdout_detail` ("fake-detail"). + printed = "".join( + call.args[0] for call in mock_print.call_args_list if call.args + ) + assert "fake-detail" in printed + finally: + Provider._global = previous_global + + def test_report_sort_calls_get_finding_sort_key(self, fake_provider): + """Test 29: report else clause calls provider.get_finding_sort_key.""" + from prowler.lib.outputs.outputs import report + + finding1 = MagicMock() + finding1.status = "PASS" + finding1.muted = False + finding1.region = "b-region" + finding1.check_metadata.Provider = "fakeexternal" + finding1.status_extended = "test1" + + finding2 = MagicMock() + finding2.status = "PASS" + finding2.muted = False + finding2.region = "a-region" + finding2.check_metadata.Provider = "fakeexternal" + finding2.status_extended = "test2" + + output_options = MagicMock() + output_options.verbose = False + output_options.status = [] + + findings = [finding1, finding2] + report(findings, fake_provider, output_options) + + # Should be sorted by region (get_finding_sort_key returns "region") + assert findings[0].region == "a-region" + assert findings[1].region == "b-region" + + def test_display_summary_table_calls_get_summary_entity(self, fake_provider): + """Test 30: display_summary_table else clause calls provider.get_summary_entity.""" + from prowler.lib.outputs.summary_table import display_summary_table + + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.ServiceName = "test_service" + finding.check_metadata.Provider = "fakeexternal" + finding.check_metadata.Severity = "high" + + output_options = MagicMock() + output_options.output_directory = "/tmp" + output_options.output_filename = "test" + output_options.output_modes = [] + + with patch("builtins.print") as mock_print: + display_summary_table([finding], fake_provider, output_options) + + printed_text = " ".join(str(c) for c in mock_print.call_args_list) + assert "Fake Host" in printed_text or "fake-host-1" in printed_text + + def test_generate_output_calls_get_finding_output_data(self, fake_provider): + """Test 31: finding.generate_output else clause calls provider.get_finding_output_data.""" + from prowler.lib.check.models import ( + CheckMetadata, + Code, + Recommendation, + Remediation, + ) + from prowler.lib.outputs.finding import Finding + + metadata = CheckMetadata( + Provider="fakeexternal", + CheckID="test_check", + CheckTitle="Test check title", + CheckType=[], + ServiceName="test", + SubServiceName="", + ResourceIdTemplate="", + Severity="high", + ResourceType="Test", + ResourceGroup="", + Description="Test description", + Risk="Test risk", + RelatedUrl="", + Remediation=Remediation( + Code=Code(CLI="", NativeIaC="", Other="", Terraform=""), + Recommendation=Recommendation( + Text="Fix it", Url="https://hub.prowler.com/check/test_check" + ), + ), + Categories=[], + DependsOn=[], + RelatedTo=[], + Notes="", + ) + + check_output = MagicMock() + check_output.check_metadata = metadata + check_output.status = "FAIL" + check_output.status_extended = "test failed" + check_output.muted = False + check_output.resource = {} + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = False + output_options.bulk_checks_metadata = {} + + finding = Finding.generate_output(fake_provider, check_output, output_options) + + assert finding.auth_method == "fake" + assert finding.account_uid == "fake-account" + assert finding.resource_name == "fake-resource" + assert finding.region == "local" + + def test_output_options_calls_get_output_options(self, fake_provider): + """Test 32: __main__.py else clause calls provider.get_output_options.""" + result = fake_provider.get_output_options(MagicMock(), {}) + + assert result is not None + assert hasattr(result, "output_directory") + + def test_html_assessment_calls_get_html_assessment_summary(self, fake_provider): + """Test 33: html.py fallback calls provider.get_html_assessment_summary.""" + from prowler.lib.outputs.html.html import HTML + + result = HTML.get_assessment_summary(fake_provider) + + assert "
Fake Assessment
" in result + + def test_compliance_output_calls_generate_compliance_output(self, fake_provider): + """Test 34: __main__.py else clause calls provider.generate_compliance_output.""" + generated_outputs = {"compliance": []} + + fake_provider.generate_compliance_output( + [], + {}, + set(), + MagicMock(), + generated_outputs, + ) + + assert "fake-compliance-output" in generated_outputs["compliance"] + + +# =========================================================================== +# 9. Base Contract Defaults +# =========================================================================== + + +class TestBaseContractDefaults: + """Tests for Provider base class default implementations.""" + + def test_from_cli_args_raises_not_implemented(self): + """Base Provider.from_cli_args raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + FakeProviderNoHelpText.from_cli_args(MagicMock(), {}) + + def test_get_output_options_raises_not_implemented(self): + """Base Provider.get_output_options raises NotImplementedError; the + generic default is applied at the call site via default_output_options.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_output_options(MagicMock(), {}) + + def test_default_output_options_builds_generic_default(self): + """default_output_options returns a generic ProviderOutputOptions so an + external provider without get_output_options still produces output + instead of aborting the run.""" + from prowler.config.config import output_file_timestamp + from prowler.providers.common.models import ( + ProviderOutputOptions, + default_output_options, + ) + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename=None, + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert isinstance(output_options, ProviderOutputOptions) + assert ( + output_options.output_filename + == f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + + def test_default_output_options_honors_explicit_filename(self): + """A user-supplied output_filename is preserved by default_output_options.""" + from prowler.providers.common.models import default_output_options + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename="custom-name", + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert output_options.output_filename == "custom-name" + + def test_get_stdout_detail_raises_not_implemented(self): + """Base Provider.get_stdout_detail raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_stdout_detail(MagicMock()) + + def test_get_finding_sort_key_returns_none(self): + """Base Provider.get_finding_sort_key returns None.""" + provider = FakeProviderNoHelpText() + assert provider.get_finding_sort_key() is None + + def test_get_summary_entity_returns_type_and_account_default(self): + """Base Provider.get_summary_entity returns (type, account_id) so the + summary table is not silently dropped for providers that don't override + it.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(account_id="acc-123"), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "acc-123" + + def test_get_summary_entity_defaults_account_to_empty_string(self): + """When the identity has no account_id, audited_entities falls back to ''.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "" + + def test_get_finding_output_data_raises_not_implemented(self): + """Base Provider.get_finding_output_data raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_finding_output_data(MagicMock()) + + def test_get_html_assessment_summary_raises_not_implemented(self): + """Base Provider.get_html_assessment_summary raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_html_assessment_summary() + + def test_generate_compliance_output_raises_not_implemented(self): + """Base Provider.generate_compliance_output raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.generate_compliance_output([], {}, set(), MagicMock(), {}) + + def test_get_mutelist_finding_args_raises_not_implemented(self): + """Base Provider.get_mutelist_finding_args raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_mutelist_finding_args() + + def test_display_compliance_table_raises_not_implemented(self): + """Base Provider.display_compliance_table raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.display_compliance_table([], {}, "fw", "out", "/tmp", False) + + def test_is_external_tool_provider_defaults_to_false(self): + """Base Provider.is_external_tool_provider returns False.""" + provider = FakeProviderNoHelpText() + assert provider.is_external_tool_provider is False + + +# =========================================================================== +# 10. Mutelist Dispatch for External Providers +# =========================================================================== + + +class TestMutelistDispatch: + """Tests for mutelist integration with external providers.""" + + def test_get_mutelist_finding_args_returns_identity(self, fake_provider): + """External provider returns identity kwargs for mutelist.""" + args = fake_provider.get_mutelist_finding_args() + + assert args == {"host_id": "fake-host-1"} + + def test_mutelist_dispatch_calls_external_provider(self, fake_provider): + """execute() uses get_mutelist_finding_args for unknown provider types.""" + from prowler.lib.check.check import execute + + # Create a mock check that returns one finding + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.Provider = "fakeexternal" + + check = MagicMock() + check.execute.return_value = [finding] + check.CheckID = "fake_check" + check.ServiceName = "fake_service" + check.Severity.value = "high" + + # Setup mutelist on the provider + fake_provider.mutelist = MagicMock() + fake_provider.mutelist.mutelist = {"Accounts": {}} + fake_provider.mutelist.is_finding_muted.return_value = True + + output_options = MagicMock() + output_options.status = [] + output_options.unix_timestamp = False + + execute(check, fake_provider, None, output_options) + + # is_finding_muted should have been called with host_id + finding + fake_provider.mutelist.is_finding_muted.assert_called_once_with( + host_id="fake-host-1", finding=finding + ) + + +# =========================================================================== +# 11. Compliance Table Dispatch for External Providers +# =========================================================================== + + +class TestComplianceTableDispatch: + """Tests for compliance table display with external providers.""" + + def test_display_compliance_table_delegates_to_provider(self, fake_provider): + """display_compliance_table uses provider method for unknown frameworks.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=True) + + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + fake_provider.display_compliance_table.assert_called_once_with( + [], + {}, + "custom_1.0_fakeexternal", + "out", + "/tmp", + False, + ) + + def test_display_compliance_table_falls_back_to_generic(self, fake_provider): + """display_compliance_table falls back to generic when provider returns False.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=False) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + + def test_display_compliance_table_falls_back_on_not_implemented(self): + """display_compliance_table falls back to generic when NotImplementedError.""" + # Use a provider that doesn't implement display_compliance_table + provider = FakeProviderNoHelpText() + Provider.set_global_provider(provider) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + display_compliance_table( + [], {}, "unknown_1.0_nohelptext", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + Provider._global = None From 662e7e9e184aa31f75fb35b0a51c4eb287e73423 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 9 Jun 2026 08:13:12 +0200 Subject: [PATCH 023/129] chore(changelog): prepare for v5.29.3 (#11505) --- prowler/CHANGELOG.md | 9 ++++++++- ui/CHANGELOG.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 8c976009f6..d28520a220 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,15 +13,22 @@ All notable changes to the **Prowler SDK** are documented in this file. - Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) +- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) ### 🐞 Fixed - `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) + +--- + +## [5.29.3] (Prowler v5.29.3) + +### 🐞 Fixed + - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) -- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470) --- diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 66d908b8fd..cbcc66fe7d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file. --- -## [1.29.3] (Prowler UNRELEASED) +## [1.29.3] (Prowler v5.29.3) ### 🐞 Fixed From 1f7caa63945a44f6f03340136c61e9b09bce4ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Tue, 9 Jun 2026 09:16:48 +0200 Subject: [PATCH 024/129] feat(api): make orphan-task recovery configurable and drop the Jira idempotency table (#11472) --- api/CHANGELOG.md | 4 +- api/docs/orphan-task-recovery.md | 55 ++-- .../commands/reconcile_orphan_tasks.py | 12 +- .../migrations/0094_scan_recovery_count.py | 17 -- ...95_reconcile_orphan_tasks_periodic_task.py | 2 +- .../api/migrations/0096_jiraissuedispatch.py | 64 ----- api/src/backend/api/models.py | 32 --- api/src/backend/config/django/base.py | 12 + api/src/backend/tasks/jobs/deletion.py | 9 - api/src/backend/tasks/jobs/integrations.py | 151 ++++------- api/src/backend/tasks/jobs/orphan_recovery.py | 206 ++++++--------- api/src/backend/tasks/jobs/scan.py | 21 +- api/src/backend/tasks/tasks.py | 16 +- api/src/backend/tasks/tests/test_deletion.py | 40 +-- .../backend/tasks/tests/test_integrations.py | 179 +------------ .../tasks/tests/test_orphan_recovery.py | 242 ++++++++++-------- api/src/backend/tasks/tests/test_scan.py | 128 --------- 17 files changed, 349 insertions(+), 841 deletions(-) delete mode 100644 api/src/backend/api/migrations/0094_scan_recovery_count.py delete mode 100644 api/src/backend/api/migrations/0096_jiraissuedispatch.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index af4b9e69bf..15466fd5fe 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,15 +6,13 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added -- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) -- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) ### 🔄 Changed - Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) -- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) ### 🐞 Fixed diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md index 38b1546bae..a47b4f36a9 100644 --- a/api/docs/orphan-task-recovery.md +++ b/api/docs/orphan-task-recovery.md @@ -1,10 +1,11 @@ # Orphan Celery task recovery When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the -task it was running can be left non-terminal forever: the `Scan` stays `EXECUTING`, -the `TaskResult` stays `STARTED`, and nothing re-runs it. This page describes the -mechanisms that detect and recover allowlisted idempotent orphans so users never -see a stuck scan and pending-task alerts do not fire. +task it was running can be left non-terminal forever: the `TaskResult` stays +`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and +recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks +are not auto-recovered (re-running a scan is not safe to do automatically); the +watchdog covers the summary/aggregation and deletion tasks. ## How recovery works @@ -13,29 +14,35 @@ see a stuck scan and pending-task alerts do not fire. (`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task (`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work - before it is force-killed. + before it is force-killed. `scan-perform`, `scan-perform-scheduled` and + `integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops + them rather than re-running and duplicating findings or Jira issues. Other + non-recovered side-effect tasks keep `acks_late=True`, so the broker can still + re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files + that did not survive the crash and so no-ops, but Security Hub re-reads findings from + the DB and re-sends them to AWS. 2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of minutes (a `django_celery_beat` periodic task created by migration). For each in-flight task result with an allowlisted idempotent task name, it pings the worker recorded on the task's `TaskResult`: - worker responds -> the task is still running, leave it alone; - - worker is gone (and the scan started before a short grace window) -> it is a + - worker is gone (and the task started before a short grace window) -> it is a real orphan: the stale task is revoked and marked terminal (clearing the - pending/started alert), and the scan is re-enqueued from scratch. + pending/started alert), and the task is re-enqueued from its stored name and + kwargs. - The re-run is safe because only tasks with proven idempotency are allowlisted. - Scan persistence, for example, clears the scan's prior findings and materialized - summary/compliance rows before re-writing them. Jira sends are allowlisted too: - each finding is reserved in a dispatch table before the external call, so a re-run - skips already-ticketed findings (the worst case is one finding missed if a worker - is hard-killed mid-send, never a duplicate issue). Other external side effects stay - terminal: the S3 upload rebuilds from worker-local files that do not survive a - crash, and report/Security Hub recovery is out of scope. + The re-run is safe because only tasks with proven idempotency are allowlisted: the + summary/aggregation tasks clear and re-write their own rows, and deletions are + idempotent. Scan tasks and external side effects are excluded: re-running a scan is + not safe to do automatically, Jira sends would create duplicate issues, the S3 + upload rebuilds from worker-local files that do not survive a crash, and + report/Security Hub recovery is out of scope. -3. **Recovery cap.** Each automatic re-enqueue increments `Scan.recovery_count`. - After `--max-attempts` recoveries (default 3) the scan is marked `FAILED` instead - of re-enqueued, so a task that repeatedly kills its worker cannot loop forever. +3. **Recovery cap.** A per-task Valkey counter limits how often the same task is + re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked + terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot + loop forever. A Postgres advisory lock ensures that, even with multiple API/worker replicas, only one reconciliation runs at a time; the others no-op. @@ -63,6 +70,18 @@ All settings have safe defaults; override via environment variables. | `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | | `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. | | `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. | +| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). | +| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. | +| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. | + +Recovery is opt-in: with the master flag off (the default) the sweep does nothing. +Once enabled, the per-group flags default to on, so every group recovers unless you +turn one off; a task whose group flag is off is marked terminal instead of +re-enqueued. + +Turning recovery off only disables this watchdog sweep; it does not change Celery's +broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still +re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag. `task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`. diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py index cdfe6b3fda..8ba8f5b342 100644 --- a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py +++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py @@ -20,7 +20,7 @@ class Command(BaseCommand): "--max-attempts", type=int, default=3, - help="Give up re-running a task after this many recovery attempts (scans are marked FAILED).", + help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.", ) parser.add_argument( "--dry-run", @@ -39,6 +39,16 @@ class Command(BaseCommand): self.stdout.write("Reconcile skipped: another run holds the lock.") return + if result.get("enabled") is False: + message = ( + "Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); " + "no orphans were recovered." + ) + if result.get("attack_paths") is not None: + message += " Attack-paths stale cleanup still ran." + self.stdout.write(message) + return + self.stdout.write( self.style.SUCCESS( "Orphan reconcile complete: " diff --git a/api/src/backend/api/migrations/0094_scan_recovery_count.py b/api/src/backend/api/migrations/0094_scan_recovery_count.py deleted file mode 100644 index 01f7af42df..0000000000 --- a/api/src/backend/api/migrations/0094_scan_recovery_count.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.15 on 2026-05-30 17:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0093_okta_provider"), - ] - - operations = [ - migrations.AddField( - model_name="scan", - name="recovery_count", - field=models.IntegerField(default=0), - ), - ] diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py index ab511a11b1..9d67404258 100644 --- a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py +++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py @@ -40,7 +40,7 @@ def delete_periodic_task(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("api", "0094_scan_recovery_count"), + ("api", "0093_okta_provider"), ("django_celery_beat", "0019_alter_periodictasks_options"), ] diff --git a/api/src/backend/api/migrations/0096_jiraissuedispatch.py b/api/src/backend/api/migrations/0096_jiraissuedispatch.py deleted file mode 100644 index f5a1a9d9a0..0000000000 --- a/api/src/backend/api/migrations/0096_jiraissuedispatch.py +++ /dev/null @@ -1,64 +0,0 @@ -import uuid - -import django.db.models.deletion -from django.db import migrations, models - -import api.rls - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0095_reconcile_orphan_tasks_periodic_task"), - ] - - operations = [ - migrations.CreateModel( - name="JiraIssueDispatch", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("inserted_at", models.DateTimeField(auto_now_add=True)), - ("finding_id", models.UUIDField()), - ( - "integration", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="jira_dispatches", - to="api.integration", - ), - ), - ( - "tenant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="api.tenant" - ), - ), - ], - options={ - "db_table": "jira_issue_dispatches", - "abstract": False, - }, - ), - migrations.AddConstraint( - model_name="jiraissuedispatch", - constraint=models.UniqueConstraint( - fields=("tenant_id", "integration_id", "finding_id"), - name="unique_jira_issue_dispatch", - ), - ), - migrations.AddConstraint( - model_name="jiraissuedispatch", - constraint=api.rls.RowLevelSecurityConstraint( - "tenant_id", - name="rls_on_jiraissuedispatch", - statements=["SELECT", "INSERT", "UPDATE", "DELETE"], - ), - ), - ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 8adbc2cf9e..3d9a26698e 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -666,9 +666,6 @@ class Scan(RowLevelSecurityProtectedModel): state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE) unique_resource_count = models.IntegerField(default=0) progress = models.IntegerField(default=0) - # Incremented by the scan-specific orphan-recovery path each time this scan is - # re-pointed to a fresh task; for observability (the retry cap is a Valkey counter). - recovery_count = models.IntegerField(default=0) scanner_args = models.JSONField(default=dict) duration = models.IntegerField(null=True, blank=True) scheduled_at = models.DateTimeField(null=True, blank=True) @@ -2001,35 +1998,6 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel): ] -class JiraIssueDispatch(RowLevelSecurityProtectedModel): - """Tracks findings already sent to a Jira integration. - - Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with - an existing dispatch row are skipped, so no duplicate issues are created. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - inserted_at = models.DateTimeField(auto_now_add=True, editable=False) - integration = models.ForeignKey( - Integration, on_delete=models.CASCADE, related_name="jira_dispatches" - ) - finding_id = models.UUIDField() - - class Meta(RowLevelSecurityProtectedModel.Meta): - db_table = "jira_issue_dispatches" - constraints = [ - models.UniqueConstraint( - fields=["tenant_id", "integration_id", "finding_id"], - name="unique_jira_issue_dispatch", - ), - RowLevelSecurityConstraint( - field="tenant_id", - name="rls_on_%(class)s", - statements=["SELECT", "INSERT", "UPDATE", "DELETE"], - ), - ] - - class SAMLToken(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 402b71eb51..38cf047ac2 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -307,6 +307,18 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h +# Orphan task recovery feature flags. The master switch is OFF by default, so task +# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group +# toggles default to enabled, so once the master is on every group recovers unless a +# group is explicitly turned off. +TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False) +TASK_RECOVERY_SUMMARIES_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True +) +TASK_RECOVERY_DELETIONS_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True +) + def label_postgres_connections(databases): """Tag each Postgres connection with ``application_name=":"`` diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index 7540f72c7e..f9ead01897 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -11,7 +11,6 @@ from api.db_utils import batch_delete, rls_transaction from api.models import ( AttackPathsScan, Finding, - JiraIssueDispatch, Provider, ProviderComplianceScore, Resource, @@ -81,14 +80,6 @@ def delete_provider(tenant_id: str, pk: str): deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), - ( - "Jira Issue Dispatches", - JiraIssueDispatch.objects.filter( - finding_id__in=Finding.all_objects.filter( - scan__provider=instance - ).values_list("id", flat=True) - ), - ), ("Findings", Finding.all_objects.filter(scan__provider=instance)), ("Resources", Resource.all_objects.filter(provider=instance)), ("Scans", Scan.all_objects.filter(provider=instance)), diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 55c1205169..5ca94057da 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -9,7 +9,7 @@ from tasks.utils import batched from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction -from api.models import Finding, Integration, JiraIssueDispatch, Provider +from api.models import Finding, Integration, Provider from api.utils import initialize_prowler_integration, initialize_prowler_provider from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.generic.generic import GenericCompliance @@ -482,115 +482,66 @@ def send_findings_to_jira( with rls_transaction(tenant_id): integration = Integration.objects.get(id=integration_id) jira_integration = initialize_prowler_integration(integration) - # Idempotency: findings already ticketed for this integration must not be - # sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues - already_sent = { - str(fid) - for fid in JiraIssueDispatch.objects.filter( - integration_id=integration_id, finding_id__in=finding_ids - ).values_list("finding_id", flat=True) - } num_tickets_created = 0 - skipped_count = 0 for finding_id in finding_ids: - if str(finding_id) in already_sent: - skipped_count += 1 - continue - - # Reserve the finding BEFORE the external call. The unique constraint on - # (tenant, integration, finding) makes the dispatch row the single source of - # truth, so a concurrent run or a retry that raced past the bulk pre-check - # cannot create a duplicate issue: created=False means another run already - # claimed it. The reservation is released below if the send does not succeed. with rls_transaction(tenant_id): - _, created = JiraIssueDispatch.objects.get_or_create( - tenant_id=tenant_id, - integration_id=integration_id, - finding_id=finding_id, + finding_instance = ( + Finding.all_objects.select_related("scan__provider") + .prefetch_related("resources") + .get(id=finding_id) ) - if not created: - skipped_count += 1 - continue - sent = False - try: - with rls_transaction(tenant_id): - finding_instance = ( - Finding.all_objects.select_related("scan__provider") - .prefetch_related("resources") - .get(id=finding_id) - ) + # Extract resource information + resource = ( + finding_instance.resources.first() + if finding_instance.resources.exists() + else None + ) + resource_uid = resource.uid if resource else "" + resource_name = resource.name if resource else "" + resource_tags = {} + if resource and hasattr(resource, "tags"): + resource_tags = resource.get_tags(tenant_id) - # Extract resource information - resource = ( - finding_instance.resources.first() - if finding_instance.resources.exists() - else None - ) - resource_uid = resource.uid if resource else "" - resource_name = resource.name if resource else "" - resource_tags = {} - if resource and hasattr(resource, "tags"): - resource_tags = resource.get_tags(tenant_id) + # Get region + region = resource.region if resource and resource.region else "" - # Get region - region = resource.region if resource and resource.region else "" + # Extract remediation information from check_metadata + check_metadata = finding_instance.check_metadata + remediation = check_metadata.get("remediation", {}) + recommendation = remediation.get("recommendation", {}) + remediation_code = remediation.get("code", {}) - # Extract remediation information from check_metadata - check_metadata = finding_instance.check_metadata - remediation = check_metadata.get("remediation", {}) - recommendation = remediation.get("recommendation", {}) - remediation_code = remediation.get("code", {}) - - # Send the individual finding to Jira - sent = bool( - jira_integration.send_finding( - check_id=finding_instance.check_id, - check_title=check_metadata.get("checktitle", ""), - severity=finding_instance.severity, - status=finding_instance.status, - status_extended=finding_instance.status_extended or "", - provider=finding_instance.scan.provider.provider, - region=region, - resource_uid=resource_uid, - resource_name=resource_name, - risk=check_metadata.get("risk", ""), - recommendation_text=recommendation.get("text", ""), - recommendation_url=recommendation.get("url", ""), - remediation_code_native_iac=remediation_code.get( - "nativeiac", "" - ), - remediation_code_terraform=remediation_code.get( - "terraform", "" - ), - remediation_code_cli=remediation_code.get("cli", ""), - remediation_code_other=remediation_code.get("other", ""), - resource_tags=resource_tags, - compliance=finding_instance.compliance or {}, - project_key=project_key, - issue_type=issue_type, - ) - ) - finally: - if not sent: - # Release the reservation so a later run can retry this finding: it - # was not ticketed (send failed or raised), so the row must not block - # a future legitimate send. - with rls_transaction(tenant_id): - JiraIssueDispatch.objects.filter( - tenant_id=tenant_id, - integration_id=integration_id, - finding_id=finding_id, - ).delete() - - if sent: - num_tickets_created += 1 - else: - logger.error(f"Failed to send finding {finding_id} to Jira") + # Send the individual finding to Jira + result = jira_integration.send_finding( + check_id=finding_instance.check_id, + check_title=check_metadata.get("checktitle", ""), + severity=finding_instance.severity, + status=finding_instance.status, + status_extended=finding_instance.status_extended or "", + provider=finding_instance.scan.provider.provider, + region=region, + resource_uid=resource_uid, + resource_name=resource_name, + risk=check_metadata.get("risk", ""), + recommendation_text=recommendation.get("text", ""), + recommendation_url=recommendation.get("url", ""), + remediation_code_native_iac=remediation_code.get("nativeiac", ""), + remediation_code_terraform=remediation_code.get("terraform", ""), + remediation_code_cli=remediation_code.get("cli", ""), + remediation_code_other=remediation_code.get("other", ""), + resource_tags=resource_tags, + compliance=finding_instance.compliance or {}, + project_key=project_key, + issue_type=issue_type, + ) + if result: + num_tickets_created += 1 + else: + logger.error(f"Failed to send finding {finding_id} to Jira") return { "created_count": num_tickets_created, - "failed_count": len(finding_ids) - num_tickets_created - skipped_count, - "skipped_count": skipped_count, + "failed_count": len(finding_ids) - num_tickets_created, } diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index d884c3fc8b..1bf5c95df2 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -37,35 +37,52 @@ ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow" # Non-terminal states that mean "a worker had this and may have died with it". IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED) -# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row, -# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would -# hit its "a scan is already executing" guard and no-op, leaving the scan stuck. -_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled") - -# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and -# rewrite their own rows. integration-jira is safe too: each finding is reserved in -# JiraIssueDispatch before the external call, so a re-run skips already-ticketed -# findings (worst case one finding missed on a mid-send crash, never a duplicate). -# Other external side effects stay terminal: integration-s3 rebuilds its upload from -# worker-local files that do not survive a crash, and report/Security Hub recovery is -# out of scope. -REENQUEUEABLE_TASKS = { - *_SCAN_TASKS, - "provider-deletion", - "tenant-deletion", - "scan-summary", - "scan-compliance-overviews", - "scan-provider-compliance-scores", - "scan-daily-severity", - "scan-finding-group-summaries", - "scan-reset-ephemeral-resources", - "integration-jira", +# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each +# group can be toggled independently by a feature flag (see config.django.base). +# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with +# external side effects are never eligible: integration-jira would create duplicate +# issues, integration-s3 rebuilds its upload from worker-local files that do not +# survive a crash, and report/Security Hub recovery is out of scope. +RECOVERY_TASK_GROUPS = { + "summaries": { + "scan-summary", + "scan-compliance-overviews", + "scan-provider-compliance-scores", + "scan-daily-severity", + "scan-finding-group-summaries", + "scan-reset-ephemeral-resources", + }, + "deletions": {"provider-deletion", "tenant-deletion"}, } -# Tasks excluded from generic recovery: attack-paths scans are handled by their own -# stale-cleanup (which also drops the temp Neo4j db), and the maintenance tasks must -# not self-recover (they run again on their own schedule). + +def reenqueueable_tasks() -> set[str]: + """Task names eligible for auto re-enqueue, honoring the per-group feature flags. + + A group whose flag is disabled is dropped, so its orphaned tasks are marked + terminal instead of re-enqueued. + """ + from django.conf import settings + + group_enabled = { + "summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED, + "deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED, + } + return { + task + for group, tasks in RECOVERY_TASK_GROUPS.items() + if group_enabled[group] + for task in tasks + } + + +# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not +# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths +# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db); +# and the maintenance tasks must not self-recover (they run again on their own schedule). _SKIP_RECOVERY = { + "scan-perform", + "scan-perform-scheduled", "attack-paths-scan-perform", "attack-paths-cleanup-stale-scans", "reconcile-orphan-tasks", @@ -166,15 +183,22 @@ def reconcile_orphans( logger.info("Orphan reconcile skipped: another run holds the lock") return {"acquired": False} - # Populate the task registry so we can re-enqueue any task by name. - import tasks.tasks # noqa: F401 + from django.conf import settings - result = _reconcile_task_results( - grace_minutes=grace_minutes, - max_attempts=max_attempts, - window_hours=window_hours, - dry_run=dry_run, - ) + if settings.TASK_RECOVERY_ENABLED: + # Populate the task registry so we can re-enqueue any task by name. + import tasks.tasks # noqa: F401 + + result = _reconcile_task_results( + grace_minutes=grace_minutes, + max_attempts=max_attempts, + window_hours=window_hours, + dry_run=dry_run, + ) + result["enabled"] = True + else: + logger.info("Orphan task recovery disabled by feature flag") + result = {"recovered": [], "failed": [], "skipped": [], "enabled": False} if not dry_run: from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans @@ -264,34 +288,27 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: task_result.date_done = now task_result.save(update_fields=["status", "date_done"]) - attempt = _recovery_attempt_count(name, kwargs_repr, window_hours) - if name not in REENQUEUEABLE_TASKS or attempt > max_attempts: - reason = ( - f"{name} is not allowlisted for auto recovery" - if name not in REENQUEUEABLE_TASKS - else f"recovery cap reached ({attempt}/{max_attempts})" - ) - _fail_domain_row(task_result.task_id, name, now) + if name not in reenqueueable_tasks(): logger.warning( - "Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason + "Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery", + task_result.task_id, + name, ) return "failed" - # Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the - # scheduled-scan "already executing" guard cannot turn recovery into a no-op. - # Falls through to the generic path only if no scan is linked yet (e.g. a - # scheduled task that died before creating one), where re-running it creates one. - if name in _SCAN_TASKS: - scan = _scan_for_task(task_result.task_id) - if scan is not None: - if not _reenqueue_scan(task_result.task_id, scan): - return "failed" - logger.info( - "Re-enqueued orphaned scan %s (was task %s)", - scan.id, - task_result.task_id, - ) - return "recovered" + # Count the attempt only once the task is allowlisted, so a task sitting in a + # disabled group does not burn its recovery budget while the flag is off (and is + # not already over the cap the moment the group is re-enabled). + attempt = _recovery_attempt_count(name, kwargs_repr, window_hours) + if attempt > max_attempts: + logger.warning( + "Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)", + task_result.task_id, + name, + attempt, + max_attempts, + ) + return "failed" task_obj = current_app.tasks.get(name) if task_obj is None: @@ -311,7 +328,6 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: task_result.task_id, name, ) - _fail_domain_row(task_result.task_id, name, now) return "failed" new_task_id = str(uuid4()) task_obj.apply_async( @@ -323,75 +339,3 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: "Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id ) return "recovered" - - -def _scan_for_task(task_id: str): - """Return the Scan linked to a Celery task id, or None (read across tenants).""" - from api.db_router import MainRouter - from api.models import Scan - - return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first() - - -def _reenqueue_scan(old_task_id: str, scan) -> bool: - """Re-run an orphaned scan via scan-perform on the existing row. - - Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the - Scan before enqueuing, so the FK is valid and a worker can never outrun the DB. - The relink is conditional on the scan still pointing at the old task, so a stale - orphan can never clobber a newer linkage. - """ - from django_celery_results.models import TaskResult - - from api.db_utils import rls_transaction - from api.models import Scan - from api.models import Task as APITask - from tasks.tasks import perform_scan_task - - tenant_id = str(scan.tenant_id) - new_task_id = str(uuid4()) - with rls_transaction(tenant_id): - locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first() - if locked_scan is None or str(locked_scan.task_id) != old_task_id: - logger.info( - "Scan %s no longer points at task %s; skipping recovery re-enqueue", - scan.id, - old_task_id, - ) - return False - task_result_new, _ = TaskResult.objects.get_or_create( - task_id=new_task_id, - defaults={"status": states.PENDING, "task_name": "scan-perform"}, - ) - APITask.objects.update_or_create( - id=new_task_id, - tenant_id=tenant_id, - defaults={"task_runner_task": task_result_new}, - ) - locked_scan.task_id = new_task_id - locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1 - locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"]) - - perform_scan_task.apply_async( - kwargs={ - "tenant_id": tenant_id, - "scan_id": str(scan.id), - "provider_id": str(scan.provider_id), - }, - task_id=new_task_id, - ) - return True - - -def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None: - """Mark a scan terminal when its task is capped/denylisted instead of re-run.""" - from api.db_utils import rls_transaction - from api.models import Scan, StateChoices - - if name in _SCAN_TASKS: - scan = _scan_for_task(old_task_id) - if scan is not None: - with rls_transaction(str(scan.tenant_id)): - Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update( - state=StateChoices.FAILED, completed_at=now - ) diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 298a2b225a..db5018db90 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -118,19 +118,6 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { _ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {} -def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None: - """Remove rows derived from a previous execution of this scan.""" - with rls_transaction(tenant_id): - Finding.all_objects.filter(scan_id=scan_id).delete() - ResourceScanSummary.objects.filter(scan_id=scan_id).delete() - ScanCategorySummary.objects.filter(scan_id=scan_id).delete() - ScanGroupSummary.objects.filter(scan_id=scan_id).delete() - ScanSummary.objects.filter(scan_id=scan_id).delete() - AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete() - ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() - ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() - - def aggregate_category_counts( categories: list[str], severity: str, @@ -489,10 +476,9 @@ def _create_compliance_summaries( ) ) - # Idempotent re-run: clear this scan's prior summaries before re-inserting, so - # a recovered scan's summary always reflects its own (re-derived) requirement - # rows rather than keeping a stale row (bulk_create ignore_conflicts alone would - # keep the old one). + # Idempotent re-run: clear this scan's prior summaries before re-inserting, so a + # recovered scan-compliance-overviews run reflects its own re-derived rows instead + # of keeping a stale one (bulk_create ignore_conflicts alone would keep the old). with rls_transaction(tenant_id): ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() if summary_objects: @@ -1039,7 +1025,6 @@ def perform_prowler_scan( scan_instance.state = StateChoices.EXECUTING scan_instance.started_at = datetime.now(tz=timezone.utc) scan_instance.save(update_fields=["state", "started_at", "updated_at"]) - _clear_scan_rerun_state(tenant_id, scan_id) # Find the mutelist processor if it exists with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 8f6b9bda0e..e617339973 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -260,7 +260,9 @@ def delete_provider_task(provider_id: str, tenant_id: str): return delete_provider(tenant_id=tenant_id, pk=provider_id) -@shared_task(base=RLSTask, name="scan-perform", queue="scans") +# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered, +# so a crashed scan is dropped rather than redelivered by the broker (as before #11416). +@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False) @handle_provider_deletion def perform_scan_task( tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None @@ -304,7 +306,14 @@ def perform_scan_task( return result -@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans") +# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick. +@shared_task( + base=RLSTask, + bind=True, + name="scan-perform-scheduled", + queue="scans", + acks_late=False, +) @handle_provider_deletion def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): """ @@ -1151,10 +1160,13 @@ def security_hub_integration_task( return upload_security_hub_integration(tenant_id, provider_id, scan_id) +# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered, +# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues). @shared_task( base=RLSTask, name="integration-jira", queue="integrations", + acks_late=False, ) def jira_integration_task( tenant_id: str, diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index e6cd51aca8..0ed8c5ddb2 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,12 +1,11 @@ from unittest.mock import call, patch -from uuid import uuid4 import pytest from django.core.exceptions import ObjectDoesNotExist from tasks.jobs.deletion import delete_provider, delete_tenant from api.attack_paths import database as graph_database -from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary +from api.models import Provider, Tenant, TenantComplianceSummary @pytest.mark.django_db @@ -35,43 +34,6 @@ class TestDeleteProvider: str(instance.id), ) - def test_delete_provider_removes_jira_dispatches( - self, - providers_fixture, - findings_fixture, - integrations_fixture, - ): - """Deleting a provider removes JiraIssueDispatch rows for its findings only.""" - instance = providers_fixture[0] - tenant_id = str(instance.tenant_id) - finding = findings_fixture[0] - integration = integrations_fixture[0] - - # Dispatch for one of the provider's findings: must be removed with it. - JiraIssueDispatch.objects.create( - tenant_id=tenant_id, - integration=integration, - finding_id=finding.id, - ) - # Dispatch for an unrelated finding: must survive the provider deletion. - unrelated = JiraIssueDispatch.objects.create( - tenant_id=tenant_id, - integration=integration, - finding_id=uuid4(), - ) - - with ( - patch( - "tasks.jobs.deletion.graph_database.get_database_name", - return_value="tenant-db", - ), - patch("tasks.jobs.deletion.graph_database.drop_subgraph"), - ): - delete_provider(tenant_id, instance.id) - - assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists() - assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists() - def test_delete_provider_does_not_exist(self, tenants_fixture): with ( patch( diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index ba8d52c193..e246405cdd 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -1640,74 +1640,14 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") - def test_send_findings_to_jira_skips_already_dispatched( - self, - mock_jira_dispatch, - mock_initialize_integration, - mock_integration_model, - mock_finding_model, - mock_rls_transaction, - ): - """A re-run skips findings already ticketed (no duplicate Jira issues).""" - mock_rls_transaction.return_value.__enter__ = MagicMock() - mock_rls_transaction.return_value.__exit__ = MagicMock() - mock_integration_model.objects.get.return_value = MagicMock() - # finding-1 was already dispatched in a prior run; finding-2 is new. - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [ - "finding-1" - ] - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) - - mock_jira_integration = MagicMock() - mock_jira_integration.send_finding.return_value = True - mock_initialize_integration.return_value = mock_jira_integration - - finding2 = MagicMock() - finding2.id = "finding-2" - finding2.check_id = "check_002" - finding2.severity = "low" - finding2.status = "FAIL" - finding2.status_extended = "" - finding2.compliance = {} - finding2.resources.exists.return_value = False - finding2.resources.first.return_value = None - finding2.scan.provider.provider = "aws" - finding2.check_metadata = { - "checktitle": "C2", - "risk": "", - "remediation": {"recommendation": {}, "code": {}}, - } - mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2 - - result = send_findings_to_jira( - "tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"] - ) - - # finding-1 skipped (already sent); only finding-2 sent -> no duplicate. - assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1} - mock_jira_integration.send_finding.assert_called_once() - assert ( - mock_jira_integration.send_finding.call_args.kwargs["check_id"] - == "check_002" - ) - - @patch("tasks.jobs.integrations.rls_transaction") - @patch("tasks.jobs.integrations.Finding") - @patch("tasks.jobs.integrations.Integration") - @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_success( self, - mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test successful sending of findings to Jira using send_finding method""" - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1799,7 +1739,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0} + assert result == {"created_count": 2, "failed_count": 0} # Verify Jira integration was initialized mock_initialize_integration.assert_called_once_with(integration) @@ -1831,10 +1771,8 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") @patch("tasks.jobs.integrations.logger") - @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_partial_failure( self, - mock_jira_dispatch, mock_logger, mock_initialize_integration, mock_integration_model, @@ -1842,8 +1780,6 @@ class TestJiraIntegration: mock_rls_transaction, ): """Test partial failure when sending findings to Jira""" - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1897,35 +1833,23 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0} + assert result == {"created_count": 2, "failed_count": 1} # Verify error was logged for the failed finding mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira") - # The failed finding's reservation is released so a later run can retry it. - mock_jira_dispatch.objects.filter.assert_any_call( - tenant_id=tenant_id, - integration_id=integration_id, - finding_id="finding-2", - ) - mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once() - @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_no_resources( self, - mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test sending findings to Jira when finding has no resources""" - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -1983,7 +1907,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} + assert result == {"created_count": 1, "failed_count": 0} # Verify send_finding was called with empty resource fields call_kwargs = mock_jira_integration.send_finding.call_args.kwargs @@ -1996,18 +1920,14 @@ class TestJiraIntegration: @patch("tasks.jobs.integrations.Finding") @patch("tasks.jobs.integrations.Integration") @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") def test_send_findings_to_jira_with_empty_check_metadata( self, - mock_jira_dispatch, mock_initialize_integration, mock_integration_model, mock_finding_model, mock_rls_transaction, ): """Test sending findings to Jira when check_metadata is empty or missing fields""" - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True) tenant_id = "tenant-123" integration_id = "integration-456" project_key = "PROJ" @@ -2050,7 +1970,7 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} + assert result == {"created_count": 1, "failed_count": 0} # Verify send_finding was called with default/empty values call_kwargs = mock_jira_integration.send_finding.call_args.kwargs @@ -2063,94 +1983,3 @@ class TestJiraIntegration: assert call_kwargs["remediation_code_cli"] == "" assert call_kwargs["remediation_code_other"] == "" assert call_kwargs["compliance"] == {} - - @patch("tasks.jobs.integrations.rls_transaction") - @patch("tasks.jobs.integrations.Finding") - @patch("tasks.jobs.integrations.Integration") - @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") - def test_send_findings_to_jira_reserves_before_sending( - self, - mock_jira_dispatch, - mock_initialize_integration, - mock_integration_model, - mock_finding_model, - mock_rls_transaction, - ): - """The dispatch row is reserved before the external Jira call (reserve-then-act).""" - mock_rls_transaction.return_value.__enter__ = MagicMock() - mock_rls_transaction.return_value.__exit__ = MagicMock() - mock_integration_model.objects.get.return_value = MagicMock() - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - - order = [] - mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: ( - order.append(("reserve", kw)) or (MagicMock(), True) - ) - - mock_jira_integration = MagicMock() - mock_jira_integration.send_finding.side_effect = lambda **kw: ( - order.append(("send", kw)) or True - ) - mock_initialize_integration.return_value = mock_jira_integration - - finding = MagicMock() - finding.id = "finding-1" - finding.check_id = "check_001" - finding.severity = "low" - finding.status = "FAIL" - finding.status_extended = "" - finding.compliance = {} - finding.resources.exists.return_value = False - finding.resources.first.return_value = None - finding.scan.provider.provider = "aws" - finding.check_metadata = { - "checktitle": "C1", - "risk": "", - "remediation": {"recommendation": {}, "code": {}}, - } - mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding - - result = send_findings_to_jira( - "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"] - ) - - assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0} - # Reservation must precede the external send. - assert [entry[0] for entry in order] == ["reserve", "send"] - # A successful send keeps the reservation (no rollback delete). - mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called() - - @patch("tasks.jobs.integrations.rls_transaction") - @patch("tasks.jobs.integrations.Finding") - @patch("tasks.jobs.integrations.Integration") - @patch("tasks.jobs.integrations.initialize_prowler_integration") - @patch("tasks.jobs.integrations.JiraIssueDispatch") - def test_send_findings_to_jira_skips_when_already_reserved( - self, - mock_jira_dispatch, - mock_initialize_integration, - mock_integration_model, - mock_finding_model, - mock_rls_transaction, - ): - """A finding that races past the bulk pre-check but loses the reservation - (created=False) is skipped without a second issue, leaving the row intact.""" - mock_rls_transaction.return_value.__enter__ = MagicMock() - mock_rls_transaction.return_value.__exit__ = MagicMock() - mock_integration_model.objects.get.return_value = MagicMock() - mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [] - # Another concurrent run already created the dispatch row. - mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False) - - mock_jira_integration = MagicMock() - mock_initialize_integration.return_value = mock_jira_integration - - result = send_findings_to_jira( - "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"] - ) - - assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1} - mock_jira_integration.send_finding.assert_not_called() - # The reservation belongs to the run that won the race; do not delete it. - mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called() diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py index b426297bbd..abfa920b8e 100644 --- a/api/src/backend/tasks/tests/test_orphan_recovery.py +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -4,17 +4,17 @@ from uuid import uuid4 import pytest from celery import states +from django.test import override_settings from django_celery_results.models import TaskResult -from api.models import Scan, StateChoices -from api.models import Task as APITask from tasks.jobs.orphan_recovery import ( _decode_celery_field, _reconcile_task_results, _recovery_attempt_count, - _reenqueue_scan, advisory_lock, is_worker_alive, + reconcile_orphans, + reenqueueable_tasks, ) @@ -130,9 +130,83 @@ class TestReconcileTaskResults: assert tr.task_id in result["failed"] mock_task.apply_async.assert_not_called() - def test_jira_integration_task_is_reenqueued(self, tenants_fixture): - """integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a - re-run skip already-ticketed findings, so recovery cannot duplicate issues.""" + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture): + """A task whose group feature flag is off stays terminal, not re-enqueued.""" + tr = _orphan_result( + name="scan-summary", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_does_not_consume_recovery_attempt( + self, tenants_fixture + ): + """A disabled-group task is failed without incrementing its Valkey attempt + counter, so re-enabling the group does not start it at the cap.""" + tr = _orphan_result( + name="scan-summary", + kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count, + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_count.assert_not_called() + + def test_scan_task_is_skipped_entirely(self, tenants_fixture): + """Scan tasks are excluded from recovery: the watchdog never touches them.""" + tr = _orphan_result( + name="scan-perform", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id not in result["recovered"] + assert tr.task_id not in result["failed"] + assert tr.task_id not in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture): + """integration-jira stays terminal: re-running it would create duplicate Jira + issues, so an orphaned send is failed instead of re-enqueued.""" tenant = tenants_fixture[0] kwargs = { "tenant_id": str(tenant.id), @@ -158,13 +232,10 @@ class TestReconcileTaskResults: grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False ) - assert tr.task_id in result["recovered"] + assert tr.task_id in result["failed"] tr.refresh_from_db() assert tr.status == states.REVOKED # stale result cleared (no pending alert) - mock_task.apply_async.assert_called_once() - call = mock_task.apply_async.call_args.kwargs - assert call["kwargs"] == kwargs - assert call["task_id"] != tr.task_id # fresh task id + mock_task.apply_async.assert_not_called() def test_skips_live_worker(self, tenants_fixture): tr = _orphan_result( @@ -246,98 +317,6 @@ class TestReconcileTaskResults: mock_task.apply_async.assert_not_called() -@pytest.mark.django_db -class TestScanRecovery: - """Scans are recovered by re-running scan-perform on the EXISTING scan row, - so even a scheduled-scan orphan (whose own task would no-op on its guard) is - actually re-executed.""" - - def _scan_orphan(self, tenant, provider, name): - old_id = str(uuid4()) - tr = TaskResult.objects.create( - task_id=old_id, - status=states.STARTED, - task_name=name, - worker="dead@gone", - task_kwargs=repr( - {"tenant_id": str(tenant.id), "provider_id": str(provider.id)} - ), - task_args=repr([]), - ) - TaskResult.objects.filter(pk=tr.pk).update( - date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60) - ) - APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr) - scan = Scan.objects.create( - name="scan-orphan", - provider=provider, - trigger=Scan.TriggerChoices.SCHEDULED, - state=StateChoices.EXECUTING, - tenant_id=tenant.id, - task_id=old_id, - recovery_count=0, - ) - return old_id, scan - - @pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"]) - def test_scan_recovered_via_scan_perform( - self, tenants_fixture, providers_fixture, name - ): - tenant, provider = tenants_fixture[0], providers_fixture[0] - old_id, scan = self._scan_orphan(tenant, provider, name) - - with ( - patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False), - patch("tasks.jobs.orphan_recovery.revoke_task"), - patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), - patch("tasks.tasks.perform_scan_task") as mock_scan_task, - ): - result = _reconcile_task_results( - grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False - ) - - assert old_id in result["recovered"] - scan.refresh_from_db() - assert str(scan.task_id) != old_id # relinked to a fresh task - assert scan.recovery_count == 1 - assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED - # Recovered by re-running scan-perform on the existing scan row (so the - # scheduled guard cannot no-op it), regardless of the original task name. - mock_scan_task.apply_async.assert_called_once() - assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str( - scan.id - ) - - def test_reenqueue_skips_when_scan_already_repointed( - self, tenants_fixture, providers_fixture - ): - # The scan already points at a newer task, so a stale orphan must not relink - # it or launch a second concurrent run against the same scan row. - tenant, provider = tenants_fixture[0], providers_fixture[0] - newer_id = str(uuid4()) - tr = TaskResult.objects.create( - task_id=newer_id, status=states.STARTED, task_name="scan-perform" - ) - APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr) - scan = Scan.objects.create( - name="scan-orphan", - provider=provider, - trigger=Scan.TriggerChoices.SCHEDULED, - state=StateChoices.EXECUTING, - tenant_id=tenant.id, - task_id=newer_id, - recovery_count=0, - ) - - with patch("tasks.tasks.perform_scan_task") as mock_scan_task: - recovered = _reenqueue_scan(str(uuid4()), scan) - - assert recovered is False - mock_scan_task.apply_async.assert_not_called() - scan.refresh_from_db() - assert scan.recovery_count == 0 - - @pytest.mark.django_db class TestOrphanRecoveryHelpers: def test_advisory_lock_acquires_and_releases(self): @@ -370,3 +349,60 @@ class TestOrphanRecoveryHelpers: with patch("redis.from_url", return_value=redis_client): assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1 assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2 + + +class TestRecoveryFeatureFlags: + def test_all_groups_enabled_by_default(self): + tasks = reenqueueable_tasks() + assert "scan-summary" in tasks + assert {"provider-deletion", "tenant-deletion"} <= tasks + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_summaries_group_flag_excludes_summary_tasks(self): + tasks = reenqueueable_tasks() + assert "scan-summary" not in tasks + assert "scan-compliance-overviews" not in tasks + assert "provider-deletion" in tasks + + @override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False) + def test_deletions_group_flag_excludes_deletion_tasks(self): + tasks = reenqueueable_tasks() + assert "provider-deletion" not in tasks + assert "tenant-deletion" not in tasks + assert "scan-summary" in tasks + + +@pytest.mark.django_db +class TestRecoveryMasterFlag: + @override_settings(TASK_RECOVERY_ENABLED=False) + def test_master_flag_disables_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results" + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_not_called() + assert result["acquired"] is True + assert result["enabled"] is False + + @override_settings(TASK_RECOVERY_ENABLED=True) + def test_master_flag_enabled_runs_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results", + return_value={"recovered": [], "failed": [], "skipped": []}, + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_called_once() diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 2ff7e44e4d..8d3d0be93a 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -32,15 +32,12 @@ from tasks.utils import CustomEncoder from api.db_router import MainRouter from api.exceptions import ProviderConnectionError from api.models import ( - AttackSurfaceOverview, Finding, MuteRule, Provider, Resource, ResourceScanSummary, Scan, - ScanCategorySummary, - ScanGroupSummary, ScanSummary, StateChoices, StatusChoices, @@ -232,131 +229,6 @@ class TestPerformScan: # Assert that failed_findings_count is 0 (finding is PASS and muted) assert scan_resource.failed_findings_count == 0 - def test_perform_prowler_scan_idempotent_on_rerun( - self, - tenants_fixture, - scans_fixture, - providers_fixture, - ): - """Re-running a scan for the same scan_id must not duplicate findings.""" - with ( - patch("api.db_utils.rls_transaction"), - patch( - "tasks.jobs.scan.initialize_prowler_provider" - ) as mock_initialize_prowler_provider, - patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", - new_callable=dict, - ), - patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks, - ): - mock_checks["aws"] = {"check1": {"compliance1"}} - - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - provider.provider = Provider.ProviderChoices.AWS - provider.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - provider_id = str(provider.id) - - stale_resource = Resource.objects.create( - tenant_id=tenant.id, - provider=provider, - uid="stale_resource_uid", - name="stale", - region="stale-region", - service="stale-service", - type="stale-type", - ) - ResourceScanSummary.objects.create( - tenant_id=tenant.id, - scan_id=scan.id, - resource_id=stale_resource.id, - service="stale-service", - region="stale-region", - resource_type="stale-type", - ) - ScanCategorySummary.objects.create( - tenant_id=tenant.id, - scan=scan, - category="stale-category", - severity=Severity.medium, - total_findings=1, - ) - ScanGroupSummary.objects.create( - tenant_id=tenant.id, - scan=scan, - resource_group="stale-group", - severity=Severity.medium, - total_findings=1, - ) - ScanSummary.objects.create( - tenant_id=tenant.id, - scan=scan, - check_id="stale_check", - service="stale-service", - severity=Severity.medium, - region="stale-region", - total=1, - ) - AttackSurfaceOverview.objects.create( - tenant_id=tenant.id, - scan=scan, - attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS, - total_findings=1, - ) - - finding = MagicMock() - finding.uid = "dup_probe_finding" - finding.status = StatusChoices.PASS - finding.status_extended = "x" - finding.severity = Severity.medium - finding.check_id = "check1" - finding.get_metadata.return_value = {"key": "value"} - finding.resource_uid = "resource_uid" - finding.resource_name = "resource_name" - finding.region = "region" - finding.service_name = "service_name" - finding.resource_type = "resource_type" - finding.resource_tags = {} - finding.muted = False - finding.raw = {} - finding.resource_metadata = {} - finding.resource_details = {} - finding.partition = "partition" - finding.compliance = {} - - mock_scan_instance = MagicMock() - mock_scan_instance.scan.return_value = [(100, [finding])] - mock_prowler_scan_class.return_value = mock_scan_instance - - mock_provider_instance = MagicMock() - mock_provider_instance.get_regions.return_value = ["region"] - mock_initialize_prowler_provider.return_value = mock_provider_instance - - # Run the same scan twice (simulating an orphan-recovery re-run). - perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"]) - perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"]) - - # Neither findings nor resources are duplicated by the re-run: findings are - # scope-deleted before re-insert; resources are upserted by (tenant, provider, uid). - assert Finding.objects.filter(scan=scan).count() == 1 - assert Resource.objects.filter(provider=provider).count() == 2 - assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1 - assert not ResourceScanSummary.objects.filter( - scan_id=scan.id, resource_id=stale_resource.id - ).exists() - assert not ScanCategorySummary.objects.filter(scan=scan).exists() - assert not ScanGroupSummary.objects.filter(scan=scan).exists() - assert not ScanSummary.objects.filter( - scan=scan, check_id="stale_check" - ).exists() - assert not AttackSurfaceOverview.objects.filter(scan=scan).exists() - @patch("tasks.jobs.scan.ProwlerScan") @patch( "tasks.jobs.scan.initialize_prowler_provider", From 62955dd16b4a32fa6d1fb6aee29e732c979fee79 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:17:23 +0200 Subject: [PATCH 025/129] feat(okta): add authenticator STIG checks (#11465) Co-authored-by: Daniel Barranquero --- .../providers/okta/authentication.mdx | 10 +- .../providers/okta/getting-started-okta.mdx | 14 +- prowler/CHANGELOG.md | 1 + prowler/providers/okta/okta_provider.py | 1 + .../okta/services/authenticator/__init__.py | 0 .../authenticator/authenticator_client.py | 6 + .../__init__.py | 0 ...r_okta_verify_fips_compliant.metadata.json | 38 +++ ...uthenticator_okta_verify_fips_compliant.py | 65 +++++ .../__init__.py | 0 ...ssword_common_password_check.metadata.json | 37 +++ ...nticator_password_common_password_check.py | 60 +++++ .../__init__.py | 0 ...assword_complexity_lowercase.metadata.json | 37 +++ ...enticator_password_complexity_lowercase.py | 60 +++++ .../__init__.py | 0 ...r_password_complexity_number.metadata.json | 37 +++ ...uthenticator_password_complexity_number.py | 60 +++++ .../__init__.py | 0 ...r_password_complexity_symbol.metadata.json | 37 +++ ...uthenticator_password_complexity_symbol.py | 60 +++++ .../__init__.py | 0 ...assword_complexity_uppercase.metadata.json | 37 +++ ...enticator_password_complexity_uppercase.py | 60 +++++ .../__init__.py | 0 ...enticator_password_history_5.metadata.json | 37 +++ .../authenticator_password_history_5.py | 60 +++++ .../__init__.py | 0 ...password_lockout_threshold_3.metadata.json | 37 +++ ...henticator_password_lockout_threshold_3.py | 60 +++++ .../__init__.py | 0 ...tor_password_maximum_age_60d.metadata.json | 37 +++ .../authenticator_password_maximum_age_60d.py | 60 +++++ .../__init__.py | 0 ...tor_password_minimum_age_24h.metadata.json | 37 +++ .../authenticator_password_minimum_age_24h.py | 60 +++++ .../__init__.py | 0 ...r_password_minimum_length_15.metadata.json | 37 +++ ...uthenticator_password_minimum_length_15.py | 60 +++++ .../authenticator/authenticator_service.py | 236 ++++++++++++++++++ .../__init__.py | 0 ...henticator_smart_card_active.metadata.json | 37 +++ .../authenticator_smart_card_active.py | 56 +++++ .../services/authenticator/lib/__init__.py | 0 .../lib/authenticator_helpers.py | 49 ++++ .../lib/password_policy_helpers.py | 80 ++++++ tests/providers/okta/okta_fixtures.py | 2 + .../authenticator/authenticator_fixtures.py | 76 ++++++ ...ticator_okta_verify_fips_compliant_test.py | 74 ++++++ ...tor_password_common_password_check_test.py | 60 +++++ ...ator_password_complexity_lowercase_test.py | 49 ++++ ...ticator_password_complexity_number_test.py | 49 ++++ ...ticator_password_complexity_symbol_test.py | 49 ++++ ...ator_password_complexity_uppercase_test.py | 49 ++++ .../authenticator_password_history_5_test.py | 49 ++++ ...cator_password_lockout_threshold_3_test.py | 49 ++++ ...enticator_password_maximum_age_60d_test.py | 49 ++++ ...enticator_password_minimum_age_24h_test.py | 49 ++++ ...ticator_password_minimum_length_15_test.py | 62 +++++ .../authenticator_service_test.py | 188 ++++++++++++++ .../authenticator_smart_card_active_test.py | 74 ++++++ 61 files changed, 2481 insertions(+), 10 deletions(-) create mode 100644 prowler/providers/okta/services/authenticator/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_client.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_service.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py create mode 100644 prowler/providers/okta/services/authenticator/lib/__init__.py create mode 100644 prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py create mode 100644 prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_fixtures.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_service_test.py create mode 100644 tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index 74e8ef7805..2e08cac8af 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -35,6 +35,7 @@ The bundled checks require the following read-only scopes: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.authenticators.read` - `okta.networkZones.read` - `okta.apiTokens.read` - `okta.roles.read` @@ -49,6 +50,7 @@ Additional scopes will be needed as more services and checks are added. These ar | `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | | `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | +| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP | | `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation | | `okta.apiTokens.read` | API token metadata and token network conditions | | `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) | @@ -136,7 +138,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 5. Grant the required OAuth scopes -On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. +On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) @@ -172,8 +174,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" uv run python prowler-cli.py okta ``` @@ -214,7 +216,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: -- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. ### Application-service checks return MANUAL on first-party apps diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 32c08433b7..e04e0d4a13 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -147,6 +147,7 @@ Prowler for Okta includes security checks across the following services: | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | | **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status | | **Network** | Network Zone blocklists for anonymized proxy sources | | **API Token** | API token owner-role validation and Network Zone restrictions | | **User** | User lifecycle automations (inactivity-based deprovisioning) | @@ -163,11 +164,12 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, API Token, User, System Log, and Identity Provider services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services: - `okta.policies.read` - `okta.brands.read` - `okta.apps.read` +- `okta.authenticators.read` - `okta.networkZones.read` - `okta.apiTokens.read` - `okta.roles.read` @@ -181,10 +183,10 @@ When additional checks are enabled — or when running against a service app tha ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read" +export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read +prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d28520a220..af0b2f6496 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) - Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) - Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) - Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index e5c968e947..8859507ec7 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -36,6 +36,7 @@ DEFAULT_SCOPES = [ "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", diff --git a/prowler/providers/okta/services/authenticator/__init__.py b/prowler/providers/okta/services/authenticator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_client.py b/prowler/providers/okta/services/authenticator/authenticator_client.py new file mode 100644 index 0000000000..3657a2821e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, +) + +authenticator_client = Authenticator(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json new file mode 100644 index 0000000000..363b95f84a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_okta_verify_fips_compliant", + "CheckTitle": "Okta Verify authenticator is active and restricts enrollment to FIPS-compliant devices", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Okta Verify authenticator** (`okta_verify`) must be present, in the `ACTIVE` status, and configured to require FIPS-compliant devices for enrollment (`settings.compliance.fips == REQUIRED`).\n\nMissing, inactive, and active-but-non-FIPS authenticators surface as distinct FAIL findings so the operator can act on the specific gap.", + "Risk": "Without FIPS-required enrollment, users can authenticate with devices whose cryptographic modules are not FIPS-validated.\n\n- **Regulatory exposure** under frameworks that mandate FIPS-validated cryptography\n- **Inconsistent assurance** across the user population\n- **Weak baseline** if the authenticator is active but the FIPS flag is `OPTIONAL` or unset", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/en-us/content/topics/mobile/ov-admin-config.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/configure-okta-verify-options.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. Activate the **Okta Verify** authenticator if it is not already `ACTIVE`.\n4. Open the authenticator's settings and set *FIPS Compliance* to **Users enrolling in Okta Verify can use FIPS compliant devices only**.\n5. Save the authenticator.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Okta Verify** authenticator is:\n- Present in the org (`key = okta_verify`)\n- In the `ACTIVE` status\n- Configured so *FIPS Compliance* is **Required** (`settings.compliance.fips == REQUIRED`)\n\nIf the organization does not require FIPS-validated authenticators, mute the check rather than disabling the FIPS toggle on a partial population.", + "Url": "https://hub.prowler.com/check/authenticator_okta_verify_fips_compliant" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273205 / OKTA-APP-001700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py new file mode 100644 index 0000000000..bf3c64b4b8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_okta_verify_fips_compliant(Check): + """STIG V-273205 / OKTA-APP-001700. + + The check requires Okta to restrict Okta Verify enrollment to FIPS-compliant devices. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate Okta Verify FIPS compliance settings.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "okta_verify", + "Okta Verify authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "okta_verify" + ) + resource = authenticator or missing_authenticator_resource( + "okta_verify", "Okta Verify authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if not authenticator: + report.status = "FAIL" + report.status_extended = "Okta Verify authenticator is missing." + elif authenticator.status.upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"Okta Verify authenticator is not active; current status is " + f"{authenticator.status}." + ) + elif authenticator.fips.upper() == "REQUIRED": + report.status = "PASS" + report.status_extended = ( + "Okta Verify authenticator requires FIPS-compliant devices " + "for enrollment." + ) + else: + current_fips = authenticator.fips or "unset" + report.status = "FAIL" + report.status_extended = ( + "Okta Verify authenticator is active but does not require " + "FIPS-compliant devices for enrollment (current value: " + f"{current_fips})." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json new file mode 100644 index 0000000000..f61ccef8a1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_common_password_check", + "CheckTitle": "Every active Okta Password Policy rejects common passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must reject passwords found in Okta's common-password dictionary (the *Restrict use of common passwords* setting).\n\nOkta evaluates Password Policies by group assignment; a custom policy with the check disabled can govern users. The check emits one finding per active policy.", + "Risk": "Without dictionary checking, users can pick passwords known to attackers from public breaches.\n\n- **Credential stuffing** succeeds with the same passwords compromised elsewhere\n- **Trivial guessing** stays viable for top-N lists (`123456`, `password`, …)\n- **Inconsistent baselines** leave users on legacy policies that allow common passwords", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Restrict use of common passwords**.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_dictionary_lookup = true # Critical: enables the common-password dictionary check\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Restrict use of common passwords* is **enabled**\n- Group assignments do not route users to legacy policies that leave the check disabled\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_common_password_check" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273208 / OKTA-APP-002980." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py new file mode 100644 index 0000000000..d90d88afeb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_common_password_check(Check): + """STIG V-273208 / OKTA-APP-002980. + + Every active Okta Password Policy must reject passwords found in the common-password dictionary. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "common-password dictionary checks" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.common_password_check is True: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json new file mode 100644 index 0000000000..42a74b6e71 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_lowercase", + "CheckTitle": "Every active Okta Password Policy requires at least one lowercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **lowercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops lowercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without lowercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Lower case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_lowercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of lowercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable lowercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_lowercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273197 / OKTA-APP-000680." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py new file mode 100644 index 0000000000..e058e27b7b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_lowercase(Check): + """STIG V-273197 / OKTA-APP-000680. + + Every active Okta Password Policy must require at least one lowercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one lowercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_lower_case is not None and policy.min_lower_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json new file mode 100644 index 0000000000..fb3d4a920b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_number", + "CheckTitle": "Every active Okta Password Policy requires at least one numeric character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **numeric** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops numeric complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without numeric complexity, the effective alphabet shrinks and dictionary words remain viable as passwords.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match plain dictionary words without numeric padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Number** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_number = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of numeric characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable numeric complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_number" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273198 / OKTA-APP-000690." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py new file mode 100644 index 0000000000..78ffe6878e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_number(Check): + """STIG V-273198 / OKTA-APP-000690. + + Every active Okta Password Policy must require at least one numeric character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one numeric character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_number is not None and policy.min_number >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json new file mode 100644 index 0000000000..1268780551 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_symbol", + "CheckTitle": "Every active Okta Password Policy requires at least one symbol character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **symbol** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops symbol complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without symbol complexity, the effective alphabet shrinks and passwords stay closer to natural-language phrases.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match dictionary words without symbol padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Symbol** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_symbol = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of symbol characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable symbol complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_symbol" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273199 / OKTA-APP-000700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py new file mode 100644 index 0000000000..208a0f51d3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_symbol(Check): + """STIG V-273199 / OKTA-APP-000700. + + Every active Okta Password Policy must require at least one symbol character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one symbol character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_symbol is not None and policy.min_symbol >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json new file mode 100644 index 0000000000..7e885364ae --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_uppercase", + "CheckTitle": "Every active Okta Password Policy requires at least one uppercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **uppercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops uppercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without uppercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Upper case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_uppercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of uppercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable uppercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_uppercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273196 / OKTA-APP-000670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py new file mode 100644 index 0000000000..1419027980 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_uppercase(Check): + """STIG V-273196 / OKTA-APP-000670. + + Every active Okta Password Policy must require at least one uppercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one uppercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_upper_case is not None and policy.min_upper_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json new file mode 100644 index 0000000000..a7191b0364 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_history_5", + "CheckTitle": "Every active Okta Password Policy remembers at least 5 previous passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must keep at least the last `5` passwords in history so users cannot immediately recycle a recently-used password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that lowers the history depth can govern users even when the default policy is compliant. The check emits one finding per active policy.", + "Risk": "A short password history lets users cycle back to compromised or trivially-guessable values shortly after a forced rotation.\n\n- **Reuse of breached passwords** within the same account\n- **Defeats forced rotation** by letting users return to a previous password\n- **Inconsistent baselines** leave users on legacy policies with shorter history than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password history for last* to `5` passwords or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_history_count = 5 # Critical: STIG-aligned history depth\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password history for last* is `5` passwords or more\n- Group assignments do not route users to legacy policies with shorter history\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_history_5" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273209 / OKTA-APP-003010." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py new file mode 100644 index 0000000000..b2eb6d16ba --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_history_5(Check): + """STIG V-273209 / OKTA-APP-003010. + + Every active Okta Password Policy must remember at least the last 5 previous passwords. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password history of at least 5 previous passwords" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.history_count is not None and policy.history_count >= 5: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(password history count: {policy.history_count})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(password history count: {policy.history_count})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json new file mode 100644 index 0000000000..e287ed38f3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_lockout_threshold_3", + "CheckTitle": "Every active Okta Password Policy locks accounts after 3 or fewer failed attempts", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must lock the account after at most `3` consecutive failed sign-in attempts.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a higher threshold (or no lockout) can govern users even when the default is compliant. The check emits one finding per active policy.", + "Risk": "A high lockout threshold (or no threshold) leaves accounts exposed to online password guessing.\n\n- **Online brute force** retains enough attempts to enumerate common passwords\n- **Credential stuffing** can iterate through breached credentials at scale\n- **Inconsistent baselines** leave users on legacy policies with weaker thresholds than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Lock out user after X unsuccessful attempts* to `3` or fewer.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_lockout_attempts = 3 # Critical: STIG-aligned lockout threshold\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Lock out user after X unsuccessful attempts* is `3` or fewer\n- Group assignments do not route users to legacy policies with higher thresholds or no lockout\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_lockout_threshold_3" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273189 / OKTA-APP-000170. Not applicable when Okta delegates password sourcing to an external directory (AD/LDAP) — mute the check in that case; the directory enforces lockout instead." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py new file mode 100644 index 0000000000..8026725c8a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_lockout_threshold_3(Check): + """STIG V-273189 / OKTA-APP-000170. + + Every active Okta Password Policy must lock accounts after no more than 3 consecutive failed login attempts. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password lockout after 3 or fewer failed attempts" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_attempts is not None and policy.max_attempts <= 3: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json new file mode 100644 index 0000000000..449904303e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_maximum_age_60d", + "CheckTitle": "Every active Okta Password Policy enforces a maximum password age of 60 days or less", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require users to change their password at least every `60` days. The check rejects both unlimited expiration (`0`, which Okta treats as *never expires*) and any value greater than `60`.\n\nOkta evaluates Password Policies by group assignment; a permissive custom policy with a longer window can govern users. The check emits one finding per active policy.", + "Risk": "Long-lived passwords give a compromised credential indefinite usefulness.\n\n- **Stolen passwords** stay valid until the user happens to change them\n- **Breach detection lag** means a leaked password can be in use for months unnoticed\n- **No expiration** is the same risk as an infinite expiration window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password expiration* to `60` days or less. Do **not** leave it disabled or set to `0` — that disables expiration entirely.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_age_days = 60 # Critical: STIG-aligned maximum age; do not use 0 (disables expiration)\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password expiration* is `60` days or less\n- Never set the value to `0`, which disables expiration\n- Group assignments do not route users to legacy policies with longer windows or disabled expiration\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_maximum_age_60d" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273201 / OKTA-APP-000745." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py new file mode 100644 index 0000000000..2dbc5d9c07 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_maximum_age_60d(Check): + """STIG V-273201 / OKTA-APP-000745. + + Every active Okta Password Policy must enforce a 60-day maximum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "maximum password age of 60 days or less" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_age_days is not None and 0 < policy.max_age_days <= 60: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json new file mode 100644 index 0000000000..8adbf3b5a0 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_age_24h", + "CheckTitle": "Every active Okta Password Policy enforces a minimum password age of 24 hours", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must prevent users from changing their password again for at least `24` hours (`1440` minutes) after the previous change.\n\nA minimum age stops users from cycling through their entire history in one session to land back on a previously-known password. The check emits one finding per active policy.", + "Risk": "Without a minimum password age, users can sidestep history and rotation requirements in minutes.\n\n- **Defeats password history** by walking through new passwords back to a previous one\n- **Defeats forced rotation** by returning to the prior password after the mandatory change\n- **Inconsistent baselines** leave users on legacy policies with shorter minimums than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password age* to `24` hours (`1440` minutes) or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_age_minutes = 1440 # Critical: STIG-aligned 24h minimum age\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password age* is `1440` minutes (`24` hours) or more\n- Group assignments do not route users to legacy policies with shorter minimums or no minimum age\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_age_24h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273200 / OKTA-APP-000740." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py new file mode 100644 index 0000000000..93ce511458 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_age_24h(Check): + """STIG V-273200 / OKTA-APP-000740. + + Every active Okta Password Policy must enforce a 24-hour minimum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password age of at least 24 hours" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_age_minutes is not None and policy.min_age_minutes >= 1440: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json new file mode 100644 index 0000000000..d7409c6bb1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_length_15", + "CheckTitle": "Every active Okta Password Policy enforces a minimum length of 15 characters", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least `15` characters in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a shorter minimum length can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Short password minimums weaken brute-force and credential-stuffing resistance for every user assigned to the affected policy.\n\n- **Brute force** completes in feasible time against short passwords\n- **Credential stuffing** succeeds with reused passwords from public breaches\n- **Inconsistent baselines** leave users on legacy policies with weaker assurance than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password length* to `15` or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_length = 15 # Critical: STIG-aligned minimum\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password length* is `15` characters or more\n- Group assignments do not route users to legacy policies with shorter minimums\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_length_15" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273195 / OKTA-APP-000650." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py new file mode 100644 index 0000000000..35e0a02b8b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_length_15(Check): + """STIG V-273195 / OKTA-APP-000650. + + Every active Okta Password Policy must enforce a minimum password length of 15 characters. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password length of at least 15 characters" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_length is not None and policy.min_length >= 15: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum length: {policy.min_length})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum length: {policy.min_length})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_service.py b/prowler/providers/okta/services/authenticator/authenticator_service.py new file mode 100644 index 0000000000..86b4084dfb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_service.py @@ -0,0 +1,236 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "password_policies": "okta.policies.read", + "authenticators": "okta.authenticators.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _int_or_none(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _bool_or_none(value) -> Optional[bool]: + """Coerce common Okta boolean shapes into a real `Optional[bool]`. + + The Okta SDK typed `bool` fields are already real booleans, but the + raw-JSON fallback paths in sibling services have surfaced both + JSON-style booleans (`true`/`false` as Python `bool` after `json.loads`) + and string-flavored ones (`"true"`/`"false"`). `bool("false")` is + `True` — so naive coercion silently flips the meaning. Reject that + explicitly. + """ + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no", ""}: + return False + return None + return bool(value) + + +class Authenticator(OktaService): + """Fetches Okta Password Policies and Authenticators for STIG checks. + + Populates: + - `self.password_policies` — keyed by policy id. Each `PasswordPolicy` + carries the projected fields the 10 password-policy checks read + (length, complexity, age, history, lockout, common-password + dictionary). The complete typed SDK response is collapsed into a + flat dataclass so the checks never reach back into the SDK shape. + - `self.authenticators` — keyed by authenticator id. Used by the + two non-password checks (Smart Card IdP, Okta Verify FIPS). + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and recorded in `self.missing_scope` + so each check can emit an explicit MANUAL finding instead of a + misleading "no resources returned". Empty granted_scopes means + "unknown" — the service attempts the fetch and lets the SDK fail + loudly. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.password_policies: dict[str, PasswordPolicy] = ( + {} + if self.missing_scope["password_policies"] + else self._list_password_policies() + ) + self.authenticators: dict[str, OktaAuthenticator] = ( + {} if self.missing_scope["authenticators"] else self._list_authenticators() + ) + + def _list_password_policies(self) -> dict[str, "PasswordPolicy"]: + """List PASSWORD policies with normalized password settings.""" + logger.info("Authenticator - Listing Okta PASSWORD policies...") + try: + return self._run(self._fetch_password_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_password_policies(self) -> dict[str, "PasswordPolicy"]: + result: dict[str, PasswordPolicy] = {} + items, err = await _paginate_shared( + lambda after: self.client.list_policies( + type="PASSWORD", after=after, limit="200" + ) + ) + if err is not None: + logger.error(f"Error listing PASSWORD policies: {err}") + return result + + for policy in items: + policy_obj = self._build_password_policy(policy) + result[policy_obj.id] = policy_obj + return result + + def _list_authenticators(self) -> dict[str, "OktaAuthenticator"]: + """List org authenticators with normalized settings.""" + logger.info("Authenticator - Listing Okta authenticators...") + try: + return self._run(self._fetch_authenticators()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_authenticators(self) -> dict[str, "OktaAuthenticator"]: + # `list_authenticators` is non-paginated in the SDK (no `after` + # parameter); inline the tuple unwrap rather than going through + # `paginate`. Same shape `application_service` uses for + # `get_first_party_app_settings`. + result: dict[str, OktaAuthenticator] = {} + sdk_result = await self.client.list_authenticators() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing authenticators: {err}") + return result + items = sdk_result[0] or [] + + for authenticator in items: + auth_obj = self._build_authenticator(authenticator) + result[auth_obj.id] = auth_obj + return result + + @staticmethod + def _build_password_policy(policy) -> "PasswordPolicy": + settings = getattr(policy, "settings", None) + password_settings = getattr(settings, "password", None) if settings else None + lockout = ( + getattr(password_settings, "lockout", None) if password_settings else None + ) + complexity = ( + getattr(password_settings, "complexity", None) + if password_settings + else None + ) + dictionary = getattr(complexity, "dictionary", None) if complexity else None + common = getattr(dictionary, "common", None) if dictionary else None + age = getattr(password_settings, "age", None) if password_settings else None + policy_id = _value(getattr(policy, "id", None)) + return PasswordPolicy( + id=policy_id, + name=_value(getattr(policy, "name", None)) or policy_id, + status=_value(getattr(policy, "status", None)), + priority=_int_or_none(getattr(policy, "priority", None)), + is_default=bool(getattr(policy, "system", False)), + max_attempts=_int_or_none(getattr(lockout, "max_attempts", None)), + min_length=_int_or_none(getattr(complexity, "min_length", None)), + min_upper_case=_int_or_none(getattr(complexity, "min_upper_case", None)), + min_lower_case=_int_or_none(getattr(complexity, "min_lower_case", None)), + min_number=_int_or_none(getattr(complexity, "min_number", None)), + min_symbol=_int_or_none(getattr(complexity, "min_symbol", None)), + min_age_minutes=_int_or_none(getattr(age, "min_age_minutes", None)), + max_age_days=_int_or_none(getattr(age, "max_age_days", None)), + history_count=_int_or_none(getattr(age, "history_count", None)), + common_password_check=_bool_or_none(getattr(common, "exclude", None)), + ) + + @staticmethod + def _build_authenticator(authenticator) -> "OktaAuthenticator": + settings = getattr(authenticator, "settings", None) + compliance = getattr(settings, "compliance", None) if settings else None + auth_id = _value(getattr(authenticator, "id", None)) + return OktaAuthenticator( + id=auth_id, + key=_value(getattr(authenticator, "key", None)), + name=_value(getattr(authenticator, "name", None)) or auth_id, + status=_value(getattr(authenticator, "status", None)), + type=_value(getattr(authenticator, "type", None)), + fips=_value(getattr(compliance, "fips", None)), + ) + + +class PasswordPolicy(BaseModel): + """Normalized Okta Password Policy settings used by checks.""" + + id: str + name: str + status: str = "" + priority: Optional[int] = None + is_default: bool = False + max_attempts: Optional[int] = None + min_length: Optional[int] = None + min_upper_case: Optional[int] = None + min_lower_case: Optional[int] = None + min_number: Optional[int] = None + min_symbol: Optional[int] = None + min_age_minutes: Optional[int] = None + max_age_days: Optional[int] = None + history_count: Optional[int] = None + common_password_check: Optional[bool] = None + + +class OktaAuthenticator(BaseModel): + """Normalized Okta Authenticator settings used by checks.""" + + id: str + key: str + name: str + status: str = "" + type: str = "" + fips: str = "" + + +class AuthenticatorSummary(BaseModel): + """Synthetic resource for org-level authenticator findings.""" + + id: str + name: str diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json new file mode 100644 index 0000000000..b8e93ae548 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_smart_card_active", + "CheckTitle": "Okta Smart Card IdP authenticator is configured and active", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Smart Card IdP authenticator** (`smart_card_idp`) must exist in the org and be in the `ACTIVE` status so certificate-based authentication is available to users and apps that require it.\n\nThe check resolves the authenticator by its built-in `key`. Missing and inactive authenticators surface as distinct FAIL findings.", + "Risk": "Without an active Smart Card authenticator, users cannot satisfy mandated certificate-based authentication and may be forced onto weaker fallback paths.\n\n- **Mandated CAC/PIV use** cannot be enforced\n- **Compliance gaps** for environments that require X.509 certificate authentication\n- **Fallback to password-only** sign-in for affected groups", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/oie/en-us/Content/Topics/identity-engine/authenticators/smart-card-authenticator.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. If the **Smart Card IdP** authenticator is not listed, click **Add Authenticator** and add it.\n4. Open the authenticator and switch its status to **ACTIVE**.\n5. Bind it to the Authentication Policies that require certificate-based auth.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Smart Card IdP** authenticator is:\n- Present in the org (`key = smart_card_idp`)\n- In the `ACTIVE` status\n- Referenced by every Authentication Policy that requires certificate-based authentication\n\nIf certificate-based authentication is not in scope for the organization, mute the check rather than disabling the authenticator.", + "Url": "https://hub.prowler.com/check/authenticator_smart_card_active" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273204 / OKTA-APP-001670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py new file mode 100644 index 0000000000..4341db8612 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_smart_card_active(Check): + """STIG V-273204 / OKTA-APP-001670. + + The check requires Okta to configure and activate the Smart Card (PIV) authenticator. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate the Smart Card IdP authenticator status.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "smart_card_idp", + "Smart Card IdP authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "smart_card_idp" + ) + resource = authenticator or missing_authenticator_resource( + "smart_card_idp", "Smart Card IdP authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if authenticator and authenticator.status.upper() == "ACTIVE": + report.status = "PASS" + report.status_extended = "Smart Card IdP authenticator is ACTIVE." + elif authenticator: + report.status = "FAIL" + report.status_extended = ( + f"Smart Card IdP authenticator is not active; current status is " + f"{authenticator.status}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Smart Card IdP authenticator is not active or missing." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/lib/__init__.py b/prowler/providers/okta/services/authenticator/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py new file mode 100644 index 0000000000..851daec5a8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + AuthenticatorSummary, + OktaAuthenticator, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def find_authenticator_by_key( + authenticators: dict[str, OktaAuthenticator], key: str +) -> OktaAuthenticator | None: + """Return the first authenticator with the requested key. + + Okta enforces unique authenticator `key` values per org for built-in + types (`okta_verify`, `smart_card_idp`, etc.), so the "first match" + is the only match in practice. If a future Okta release relaxes + that and returns duplicates, only the first is evaluated — STIG + semantics need refining at that point. + """ + for authenticator in authenticators.values(): + if authenticator.key == key: + return authenticator + return None + + +def missing_authenticator_resource(key: str, name: str) -> AuthenticatorSummary: + """Build a synthetic resource for a missing authenticator.""" + return AuthenticatorSummary(id=f"{key}-missing", name=name) + + +def missing_authenticators_scope_finding( + metadata, org_domain: str, key: str, name: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when authenticators cannot be listed.""" + resource = AuthenticatorSummary(id=f"{key}-scope-missing", name=name) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta authenticators to evaluate {name}: the Okta " + f"service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py new file mode 100644 index 0000000000..08aa64a3a6 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + PasswordPolicy, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def active_password_policies( + password_policies: dict[str, PasswordPolicy], +) -> list[PasswordPolicy]: + """Return active password policies sorted by priority. + + Treats `policy.status == ""` as ACTIVE: the typed Okta SDK + occasionally returns policies without a `status` field populated + (the SDK enum doesn't cover every server-side value Okta has + shipped). Dropping those would silently hide real policies — we + 'd rather evaluate them and let the per-field comparator decide. + """ + return sorted( + [ + policy + for policy in password_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def password_policy_label(policy: PasswordPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Password Policy {policy.name} (priority {priority}, {kind})" + + +def no_active_password_policies_finding( + metadata, org_domain: str, requirement: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active password policies exist.""" + placeholder = PasswordPolicy( + id="password-policies-missing", + name="(no active password policies)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No active Okta Password Policies were returned by the API. " + f"The organization must enforce: {requirement}." + ) + return report + + +def missing_password_policies_scope_finding( + metadata, org_domain: str, scope: str, requirement: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Password Policies cannot be listed.""" + placeholder = PasswordPolicy( + id="password-policies-scope-missing", + name="(scope not granted)", + status="UNKNOWN", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Password Policies to evaluate {requirement}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index d1018a65eb..5c3d0e43c3 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -20,6 +20,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", @@ -37,6 +38,7 @@ def set_mocked_okta_provider( "okta.policies.read", "okta.brands.read", "okta.apps.read", + "okta.authenticators.read", "okta.networkZones.read", "okta.apiTokens.read", "okta.roles.read", diff --git a/tests/providers/okta/services/authenticator/authenticator_fixtures.py b/tests/providers/okta/services/authenticator/authenticator_fixtures.py new file mode 100644 index 0000000000..f15603221b --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_fixtures.py @@ -0,0 +1,76 @@ +from unittest import mock + +from prowler.providers.okta.services.authenticator.authenticator_service import ( + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_authenticator_client( + password_policies: dict = None, + authenticators: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.password_policies = password_policies or {} + client.authenticators = authenticators or {} + client.missing_scope = missing_scope or { + "password_policies": None, + "authenticators": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def password_policy( + policy_id: str = "pol-password", + name: str = "Default Password Policy", + *, + status: str = "ACTIVE", + priority: int = 1, + max_attempts: int = 3, + min_length: int = 15, + min_upper_case: int = 1, + min_lower_case: int = 1, + min_number: int = 1, + min_symbol: int = 1, + min_age_minutes: int = 1440, + max_age_days: int = 60, + history_count: int = 5, + common_password_check: bool = True, +): + return PasswordPolicy( + id=policy_id, + name=name, + status=status, + priority=priority, + max_attempts=max_attempts, + min_length=min_length, + min_upper_case=min_upper_case, + min_lower_case=min_lower_case, + min_number=min_number, + min_symbol=min_symbol, + min_age_minutes=min_age_minutes, + max_age_days=max_age_days, + history_count=history_count, + common_password_check=common_password_check, + ) + + +def authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + name: str = "Okta Verify", + *, + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return OktaAuthenticator( + id=auth_id, + key=key, + name=name, + status=status, + type="app", + fips=fips, + ) diff --git a/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py new file mode 100644 index 0000000000..467dc44611 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_okta_verify_fips_compliant." + "authenticator_okta_verify_fips_compliant.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_okta_verify_fips_compliant.authenticator_okta_verify_fips_compliant import ( + authenticator_okta_verify_fips_compliant, + ) + + return authenticator_okta_verify_fips_compliant().execute() + + +class Test_authenticator_okta_verify_fips_compliant: + def test_okta_verify_fips_required_passes(self): + okta_verify = authenticator(key="okta_verify", fips="REQUIRED") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == okta_verify.id + + def test_okta_verify_without_fips_required_fails(self): + okta_verify = authenticator(key="okta_verify", fips="OPTIONAL") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "FIPS" in findings[0].status_extended + + def test_missing_okta_verify_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Okta Verify authenticator is missing." in findings[0].status_extended + + def test_inactive_okta_verify_surfaces_current_status(self): + okta_verify = authenticator(key="okta_verify", status="INACTIVE") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py new file mode 100644 index 0000000000..c0e49deb8c --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py @@ -0,0 +1,60 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_common_password_check.authenticator_password_common_password_check.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_common_password_check.authenticator_password_common_password_check import ( + authenticator_password_common_password_check, + ) + + return authenticator_password_common_password_check().execute() + + +class Test_authenticator_password_common_password_check: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_missing_password_policies_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + {}, + missing_scope={"password_policies": "okta.policies.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(common_password_check=True) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(common_password_check=False) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py new file mode 100644 index 0000000000..8483f46552 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase import ( + authenticator_password_complexity_lowercase, + ) + + return authenticator_password_complexity_lowercase().execute() + + +class Test_authenticator_password_complexity_lowercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_lower_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_lower_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py new file mode 100644 index 0000000000..cac8f8b444 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_number.authenticator_password_complexity_number.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_number.authenticator_password_complexity_number import ( + authenticator_password_complexity_number, + ) + + return authenticator_password_complexity_number().execute() + + +class Test_authenticator_password_complexity_number: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_number=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_number=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py new file mode 100644 index 0000000000..93ed18a36d --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_symbol.authenticator_password_complexity_symbol.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_symbol.authenticator_password_complexity_symbol import ( + authenticator_password_complexity_symbol, + ) + + return authenticator_password_complexity_symbol().execute() + + +class Test_authenticator_password_complexity_symbol: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_symbol=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_symbol=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py new file mode 100644 index 0000000000..74ab626e52 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase import ( + authenticator_password_complexity_uppercase, + ) + + return authenticator_password_complexity_uppercase().execute() + + +class Test_authenticator_password_complexity_uppercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_upper_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_upper_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py new file mode 100644 index 0000000000..e6741c53c2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_history_5.authenticator_password_history_5.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_history_5.authenticator_password_history_5 import ( + authenticator_password_history_5, + ) + + return authenticator_password_history_5().execute() + + +class Test_authenticator_password_history_5: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(history_count=5) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(history_count=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py new file mode 100644 index 0000000000..35c7026e11 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3 import ( + authenticator_password_lockout_threshold_3, + ) + + return authenticator_password_lockout_threshold_3().execute() + + +class Test_authenticator_password_lockout_threshold_3: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_attempts=3) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_attempts=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py new file mode 100644 index 0000000000..938b29a290 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d import ( + authenticator_password_maximum_age_60d, + ) + + return authenticator_password_maximum_age_60d().execute() + + +class Test_authenticator_password_maximum_age_60d: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_age_days=60) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_age_days=61) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py new file mode 100644 index 0000000000..0fbe562330 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h import ( + authenticator_password_minimum_age_24h, + ) + + return authenticator_password_minimum_age_24h().execute() + + +class Test_authenticator_password_minimum_age_24h: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_age_minutes=1440) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_age_minutes=1439) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py new file mode 100644 index 0000000000..2608c42998 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py @@ -0,0 +1,62 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_length_15.authenticator_password_minimum_length_15.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_length_15.authenticator_password_minimum_length_15 import ( + authenticator_password_minimum_length_15, + ) + + return authenticator_password_minimum_length_15().execute() + + +class Test_authenticator_password_minimum_length_15: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_length=15) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_length=14) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id + + def test_multiple_active_policies_emit_one_finding_each(self): + compliant = password_policy(policy_id="pol-good", name="Strict", min_length=15) + weak = password_policy( + policy_id="pol-weak", name="Weak", min_length=8, priority=2 + ) + findings = _run_check( + build_authenticator_client({compliant.id: compliant, weak.id: weak}) + ) + assert len(findings) == 2 + by_name = {finding.resource_name: finding for finding in findings} + assert by_name["Strict"].status == "PASS" + assert by_name["Weak"].status == "FAIL" diff --git a/tests/providers/okta/services/authenticator/authenticator_service_test.py b/tests/providers/okta/services/authenticator/authenticator_service_test.py new file mode 100644 index 0000000000..d66af92ca2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_service_test.py @@ -0,0 +1,188 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_password_policy( + policy_id: str = "pol-password", name: str = "Default", common_exclude=True +): + return SimpleNamespace( + id=policy_id, + name=name, + priority=1, + status="ACTIVE", + system=True, + settings=SimpleNamespace( + password=SimpleNamespace( + lockout=SimpleNamespace(max_attempts=3), + complexity=SimpleNamespace( + min_length=15, + min_upper_case=1, + min_lower_case=1, + min_number=1, + min_symbol=1, + dictionary=SimpleNamespace( + common=SimpleNamespace(exclude=common_exclude) + ), + ), + age=SimpleNamespace( + min_age_minutes=1440, + max_age_days=60, + history_count=5, + ), + ) + ), + ) + + +def _sdk_authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return SimpleNamespace( + id=auth_id, + key=key, + name="Okta Verify" if key == "okta_verify" else "Smart Card IdP", + status=status, + type="app", + settings=SimpleNamespace(compliance=SimpleNamespace(fips=fips)), + ) + + +class Test_Authenticator_service: + def test_fetches_password_policies_and_authenticators(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy() + okta_verify = _sdk_authenticator() + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([okta_verify], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + + service = Authenticator(provider) + + assert isinstance(service.password_policies[policy.id], PasswordPolicy) + assert service.password_policies[policy.id].min_length == 15 + assert service.password_policies[policy.id].common_password_check is True + assert isinstance(service.authenticators[okta_verify.id], OktaAuthenticator) + assert service.authenticators[okta_verify.id].fips == "REQUIRED" + + def test_common_password_exclude_false_is_not_compliant(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy(common_exclude=False) + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies[policy.id].common_password_check is False + + def test_paginates_password_policies(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_password_policy("pol-1", "First") + page_2 = _sdk_password_policy("pol-2", "Second") + quote = chr(34) + next_link = ( + "; " + f"rel={quote}next{quote}" + ) + calls = [] + + async def fake_list_policies(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert calls == [None, "cursor-2"] + assert set(service.password_policies.keys()) == {"pol-1", "pol-2"} + + def test_missing_scopes_skip_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apps.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("Authenticator API calls should not run") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fail_if_called + mocked.list_authenticators = fail_if_called + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.missing_scope["password_policies"] == "okta.policies.read" + assert service.missing_scope["authenticators"] == "okta.authenticators.read" + assert service.password_policies == {} + assert service.authenticators == {} + + def test_returns_empty_collections_on_api_errors(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing + mocked.list_authenticators = failing + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies == {} + assert service.authenticators == {} diff --git a/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py new file mode 100644 index 0000000000..c85527f0be --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_smart_card_active.authenticator_smart_card_active.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_smart_card_active.authenticator_smart_card_active import ( + authenticator_smart_card_active, + ) + + return authenticator_smart_card_active().execute() + + +class Test_authenticator_smart_card_active: + def test_smart_card_active_passes(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="ACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == smart_card.id + + def test_missing_smart_card_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not active" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended + + def test_inactive_smart_card_fails(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="INACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended From 7e60e8f8dae30c90248459765828c8f62fcf8b9d Mon Sep 17 00:00:00 2001 From: Ashishraymajhi Date: Tue, 9 Jun 2026 14:59:03 +0530 Subject: [PATCH 026/129] feat(m365): add entra_service_prinicipal_privileged_role_no_owners_check (#11189) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../m365/services/entra/entra_service.py | 61 +++- .../__init__.py | 0 ...al_privileged_role_no_owners.metadata.json | 40 +++ ...ice_principal_privileged_role_no_owners.py | 71 +++++ ...rincipal_privileged_role_no_owners_test.py | 299 ++++++++++++++++++ 6 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py create mode 100644 tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index af0b2f6496..6b02ecdf7d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) - AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) +- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) ### 🐞 Fixed diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index a09ee32da0..c3ea05acee 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -1197,6 +1197,10 @@ OAuthAppInfo service_principals_by_app_id = { sp.app_id: sp for sp in service_principals.values() if sp.app_id } + # Remember each SP's parent application object ID so the owner + # lookup below can address it directly without re-walking + # /applications. + application_object_id_by_sp_id: Dict[str, str] = {} app_response = await self.client.applications.get() while app_response: for app in getattr(app_response, "value", []) or []: @@ -1207,6 +1211,10 @@ OAuthAppInfo if target_sp is None: continue + app_object_id = getattr(app, "id", None) + if app_object_id: + application_object_id_by_sp_id[target_sp.id] = app_object_id + for cred in getattr(app, "password_credentials", []) or []: target_sp.password_credentials.append( PasswordCredential( @@ -1257,6 +1265,49 @@ OAuthAppInfo next_link ).get() + # Resolve owners only for service principals that hold a permanent + # Tier 0 directory role. Owner ownership of the SP object or its + # parent app registration is a credential-rotation escalation path + # outside PIM and Conditional Access; fetching owners for every + # consented SP would multiply Graph traffic for no benefit. + for sp in service_principals.values(): + if not sp.directory_role_template_ids: + continue + try: + sp_owners_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ).owners.get() + ) + sp.sp_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(sp_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + app_object_id = application_object_id_by_sp_id.get(sp.id) + if not app_object_id: + continue + try: + app_owners_response = ( + await self.client.applications.by_application_id( + app_object_id + ).owners.get() + ) + sp.app_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(app_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -1436,7 +1487,7 @@ class PlatformConditions(BaseModel): @validator("include_platforms", "exclude_platforms", pre=True) @classmethod - def normalize_platforms(cls, values): + def normalize_platforms(cls, values): # noqa: vulture if not values: return [] @@ -1832,6 +1883,12 @@ class ServicePrincipal(BaseModel): key_credentials: List of key credentials (certificates). directory_role_template_ids: List of directory role template IDs permanently assigned to this service principal. + sp_owner_ids: Principal IDs that own the service principal object. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment, to keep Graph traffic bounded. + app_owner_ids: Principal IDs that own the parent app registration. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment. """ id: str @@ -1841,6 +1898,8 @@ class ServicePrincipal(BaseModel): password_credentials: List[PasswordCredential] = [] key_credentials: List[KeyCredential] = [] directory_role_template_ids: List[str] = [] + sp_owner_ids: List[str] = [] + app_owner_ids: List[str] = [] class AppRegistration(BaseModel): diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json new file mode 100644 index 0000000000..4822d8956d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_service_principal_privileged_role_no_owners", + "CheckTitle": "Service principals with privileged Entra directory roles must have no owners", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.", + "Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.", + "Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_service_principal_no_secrets_for_permanent_tier0_roles" + ], + "Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer." +} diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py new file mode 100644 index 0000000000..f3af26e23f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py @@ -0,0 +1,71 @@ +"""Check for service principals with privileged roles that have owners.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_service_principal_privileged_role_no_owners(Check): + """Service principal with a permanent Tier 0 directory role has no owners. + + Owners of a service principal or its parent app registration can rotate + credentials and sign in as the service principal, inheriting its privileged + directory role outside PIM approval flows and Conditional Access policies + targeting user accounts. + + - PASS: The service principal does not hold a permanent Tier 0 directory + role, or it does but has zero owners on both the service principal and + its parent app registration. + - FAIL: The service principal holds a permanent Tier 0 directory role and + has at least one owner on either the service principal or its parent + app registration. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the privileged service principal owner check. + + Returns: + A list of reports, one per service principal owned by the audited + tenant. + """ + findings = [] + for sp in entra_client.service_principals.values(): + report = CheckReportM365( + metadata=self.metadata(), + resource=sp, + resource_name=sp.name, + resource_id=sp.id, + ) + + if not sp.directory_role_template_ids: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' has no permanent Tier 0 " + f"directory role assignments." + ) + findings.append(report) + continue + + unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids) + tier0_role_count = len(sp.directory_role_template_ids) + + if unique_owners: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has " + f"{len(unique_owners)} owner(s) " + f"({len(sp.sp_owner_ids)} on the service principal, " + f"{len(sp.app_owner_ids)} on the parent app registration)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has no owners on " + f"either the service principal or its parent app registration." + ) + + findings.append(report) + return findings diff --git a/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py new file mode 100644 index 0000000000..e62f9eaa08 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py @@ -0,0 +1,299 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ServicePrincipal +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10" +PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814" + + +class Test_entra_service_principal_privileged_role_no_owners: + """Tests for the entra_service_principal_privileged_role_no_owners check.""" + + def test_no_service_principals(self): + """No service principals configured: expected no findings.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = {} + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 0 + + def test_service_principal_no_tier0_roles(self): + """Service principal without Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="NonPrivilegedApp", + app_id=str(uuid4()), + directory_role_template_ids=[], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "NonPrivilegedApp" + assert ( + "no permanent Tier 0 directory role assignments" + in result[0].status_extended + ) + + def test_service_principal_tier0_no_owners(self): + """Privileged SP with no owners on SP or app: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="SecureApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "SecureApp" + assert "no owners" in result[0].status_extended + + def test_service_principal_tier0_with_sp_owners(self): + """Privileged SP with owners on SP only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + owner_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="RiskyApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[owner_id], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == sp_id + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "0 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_app_owners(self): + """Privileged SP with owners on parent app only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + app_owner_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="AppRegOwnerRisk", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "0 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_both_owners(self): + """Privileged SP with distinct owners on both SP and app: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + sp_owner_id = str(uuid4()) + app_owner_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="HighRiskApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN], + sp_owner_ids=[sp_owner_id], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended + assert "2 owner(s)" in result[0].status_extended + + def test_service_principal_tier0_same_owner_on_sp_and_app(self): + """Same principal owns both SP and parent app: owner count deduplicated.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + shared_owner_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="DualOwnedApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[shared_owner_id], + app_owner_ids=[shared_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended From b2d74711d99b390ea89cb54338550064f01b62eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:01:46 +0200 Subject: [PATCH 027/129] chore(deps): bump dulwich to 1.2.5 and pyjwt to 2.13.0 for osv-scanner (#11499) --- api/CHANGELOG.md | 4 ++ api/pyproject.toml | 16 ++++++-- api/src/backend/config/django/testing.py | 5 +++ api/uv.lock | 51 +++++++++++++----------- osv-scanner.toml | 5 ++- prowler/CHANGELOG.md | 4 ++ pyproject.toml | 4 +- uv.lock | 50 ++++++++++++----------- 8 files changed, 86 insertions(+), 53 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 15466fd5fe..dc404079ec 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -18,6 +18,10 @@ All notable changes to the **Prowler API** are documented in this file. - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + --- ## [1.30.3] (Prowler v5.29.3) diff --git a/api/pyproject.toml b/api/pyproject.toml index 5d996878fa..5e18ffac20 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -226,7 +226,7 @@ constraint-dependencies = [ "drf-simple-apikey==2.2.1", "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", - "dulwich==0.23.0", + "dulwich==1.2.5", "duo-client==5.5.0", "durationpy==0.10", "email-validator==2.2.0", @@ -354,7 +354,7 @@ constraint-dependencies = [ "pydantic-core==2.41.5", "pygithub==2.8.0", "pygments==2.20.0", - "pyjwt==2.12.1", + "pyjwt==2.13.0", "pylint==3.2.5", "pymsalruntime==0.18.1", "pynacl==1.6.2", @@ -443,7 +443,17 @@ constraint-dependencies = [ # The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires # microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the # SDK's hard pin; override it to the patched, kiota-aligned version. +# +# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies]. +# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0 +# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these +# against the SDK's hard pins, so override them to the patched versions until the SDK +# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an +# override replaces the whole requirement; bare pyjwt would drop it from the consumers +# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive. override-dependencies = [ "okta==3.4.2", - "microsoft-kiota-abstractions==1.9.9" + "microsoft-kiota-abstractions==1.9.9", + "dulwich==1.2.5", + "pyjwt[crypto]==2.13.0" ] diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 75779f5a68..a1e5c29fb3 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -34,3 +34,8 @@ DRF_API_KEY = { # JWT SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405 +# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real +# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256. +SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405 + "DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod" +) diff --git a/api/uv.lock b/api/uv.lock index d90eff5fdf..9689a9cd0a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -163,7 +163,7 @@ constraints = [ { name = "drf-simple-apikey", specifier = "==2.2.1" }, { name = "drf-spectacular", specifier = "==0.27.2" }, { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, - { name = "dulwich", specifier = "==0.23.0" }, + { name = "dulwich", specifier = "==1.2.5" }, { name = "duo-client", specifier = "==5.5.0" }, { name = "durationpy", specifier = "==0.10" }, { name = "email-validator", specifier = "==2.2.0" }, @@ -291,7 +291,7 @@ constraints = [ { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygithub", specifier = "==2.8.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.12.1" }, + { name = "pyjwt", specifier = "==2.13.0" }, { name = "pylint", specifier = "==3.2.5" }, { name = "pymsalruntime", specifier = "==0.18.1" }, { name = "pynacl", specifier = "==1.6.2" }, @@ -374,8 +374,10 @@ constraints = [ { name = "zstd", specifier = "==1.5.7.3" }, ] overrides = [ + { name = "dulwich", specifier = "==1.2.5" }, { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, { name = "okta", specifier = "==3.4.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, ] [[package]] @@ -393,7 +395,7 @@ version = "1.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, { name = "requests" }, ] @@ -1074,7 +1076,7 @@ dependencies = [ { name = "pkginfo" }, { name = "psutil", marker = "sys_platform != 'cygwin'" }, { name = "py-deviceid" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyopenssl" }, { name = "requests", extra = ["socks"] }, ] @@ -2457,7 +2459,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "djangorestframework" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } wheels = [ @@ -2576,24 +2578,27 @@ wheels = [ [[package]] name = "dulwich" -version = "0.23.0" +version = "1.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" }, - { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" }, + { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" }, + { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" }, + { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" }, ] [[package]] @@ -4031,7 +4036,7 @@ dependencies = [ { name = "pycryptodomex" }, { name = "pydantic" }, { name = "pydash" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, { name = "pyyaml" }, { name = "requests" }, @@ -4873,11 +4878,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -5785,7 +5790,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" } wheels = [ diff --git a/osv-scanner.toml b/osv-scanner.toml index 5d029efdf6..59408b8709 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -12,8 +12,9 @@ reason = """ CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes weak encryption, but the underlying issue is that callers may pick a short HMAC secret — key-length enforcement is the application's responsibility, not -a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and -enforce key strength in our own auth code, so this advisory does not apply. +a defect in the library. We are on pyjwt 2.13.0 (which now also emits an +InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in +our own auth code, so this advisory does not apply. Re-evaluate when a non-disputed advisory or upstream fix lands. """ diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6b02ecdf7d..62106c6594 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -32,6 +32,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + --- ## [5.29.1] (Prowler v5.29.1) diff --git a/pyproject.toml b/pyproject.toml index a87e200e4b..f029f3d6f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ "dash-bootstrap-components==2.0.3", "defusedxml==0.7.1", "detect-secrets==1.5.0", - "dulwich==0.23.0", + "dulwich==1.2.5", "google-api-python-client==2.163.0", "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", @@ -314,7 +314,7 @@ constraint-dependencies = [ "pydash==8.0.6", "pyflakes==3.2.0", "pygments==2.20.0", - "pyjwt==2.12.1", + "pyjwt==2.13.0", "pylint==3.3.4", "pynacl==1.6.2", "pyopenssl==26.2.0", diff --git a/uv.lock b/uv.lock index d385009f63..e42e3ba8af 100644 --- a/uv.lock +++ b/uv.lock @@ -166,7 +166,7 @@ constraints = [ { name = "pydash", specifier = "==8.0.6" }, { name = "pyflakes", specifier = "==3.2.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.12.1" }, + { name = "pyjwt", specifier = "==2.13.0" }, { name = "pylint", specifier = "==3.3.4" }, { name = "pynacl", specifier = "==1.6.2" }, { name = "pyopenssl", specifier = "==26.2.0" }, @@ -1774,29 +1774,33 @@ wheels = [ [[package]] name = "dulwich" -version = "0.23.0" +version = "1.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/8d/d725f0c9ddb218c7d9e3e02ee4545e998b57e1d7c12f5ab3e2d61f577410/dulwich-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c13b0d5a9009cde23ecb8cb201df6e23e2a7a82c5e2d6ba6443fbb322c9befc6", size = 973413, upload-time = "2025-06-21T17:56:04.641Z" }, - { url = "https://files.pythonhosted.org/packages/97/82/0316022bd64b3525acfebc88b6b7506d04b0402b7dbfb746cd15529b9ea8/dulwich-0.23.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a68faf8612bf93de1285048d6ad13160f0fb3c5596a86e694e78f4e212886fa5", size = 1050614, upload-time = "2025-06-21T17:56:07.084Z" }, - { url = "https://files.pythonhosted.org/packages/65/a0/e3f71d6d74809cd9245d3d2921448fd32a8417f74b4e912e82cef0cf5098/dulwich-0.23.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d971566826f16ec67c70641c1fbdb337323aa5b533799bc5a4641f4750e73b36", size = 1052830, upload-time = "2025-06-21T17:56:08.682Z" }, - { url = "https://files.pythonhosted.org/packages/c1/38/8dd887d9b64f47f8097e207ed7e8d5dd640a19aa763e632d97174961585f/dulwich-0.23.0-cp310-cp310-win32.whl", hash = "sha256:27d970adf539806dfc4fe3e4c9e8dc6ebf0318977a56e24d22f13413535a51ba", size = 642779, upload-time = "2025-06-21T17:56:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ca/a345085526ac3b7aaa891ca4ec7ad9375cd8d017e42d4dbf20a443231275/dulwich-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:025178533e884ffdb0d9d8db4b8870745d438cbfecb782fd1b56c3b6438e86cf", size = 658637, upload-time = "2025-06-21T17:56:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" }, - { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/60929b502d6541cb015beb9f1da82600aac64d5f705d0188aaf44a7aa77f/dulwich-1.2.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:46db47394ba8a95748ae739f5d3a5a3e1724a2f857bf2437bc71bfc0baaed91d", size = 1400236, upload-time = "2026-05-28T22:26:50.998Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/25de359a9249cc05a58c2500babfe2adff174931f2fa3fe97c700ca16626/dulwich-1.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66aded7d364341b55941973a1562323f25bd205f0809692b687ec36ccd31242c", size = 1382996, upload-time = "2026-05-28T22:26:53.545Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/640fee144007262096173f5fafd04cc7e5a0d72b0ceeeb9c9a51d99abc43/dulwich-1.2.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dd9569bc26174a3437d749114d36c81fc6c7478b55370ae50125e34e9629e4fe", size = 1471811, upload-time = "2026-05-28T22:26:55.044Z" }, + { url = "https://files.pythonhosted.org/packages/51/2a/348c1f0baa0c42dc79a9f503463ddb00452c234ec5c9e20b43530d78528e/dulwich-1.2.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:827366331603150de5976d72dd456a3fd5fc91e856471dc1d10fd64758c05f02", size = 1498006, upload-time = "2026-05-28T22:26:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/95f51bcbc2ce86beea49a67f61228be86bf614a24aa714a8c59e0abdd153/dulwich-1.2.5-cp310-cp310-win32.whl", hash = "sha256:6c683c0f4a062894b6826c61102d415dae86ade61a10003c82ccc2b91858d5fb", size = 1066964, upload-time = "2026-05-28T22:26:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/68/c1/ffa02a1623c3d668de8e66b654187bb8dc24c085224644e5554537ee4642/dulwich-1.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:a6620963196c49212c511cd909f367dacf771f199a27d116f357cc671ea956c7", size = 1079537, upload-time = "2026-05-28T22:26:59.319Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" }, + { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" }, + { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" }, + { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" }, ] [[package]] @@ -3403,7 +3407,7 @@ requires-dist = [ { name = "dash-bootstrap-components", specifier = "==2.0.3" }, { name = "defusedxml", specifier = "==0.7.1" }, { name = "detect-secrets", specifier = "==1.5.0" }, - { name = "dulwich", specifier = "==0.23.0" }, + { name = "dulwich", specifier = "==1.2.5" }, { name = "google-api-python-client", specifier = "==2.163.0" }, { name = "google-auth-httplib2", specifier = "==0.2.0" }, { name = "h2", specifier = "==4.3.0" }, @@ -3711,14 +3715,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] From 6c559fbb8dc5e1a7e492315e40fda1344732c66a Mon Sep 17 00:00:00 2001 From: StylusFrost <43682773+StylusFrost@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:45:34 +0200 Subject: [PATCH 028/129] feat(sdk): discover external universal compliance frameworks via entry points (#11490) --- prowler/CHANGELOG.md | 1 + prowler/config/config.py | 29 ++++- prowler/lib/check/compliance_models.py | 57 +++++++-- .../check/universal_compliance_models_test.py | 121 ++++++++++++++++++ .../external/test_dynamic_provider_loading.py | 85 ++++++++++++ 5 files changed, 279 insertions(+), 14 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 62106c6594..dd35527c8e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) - `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) +- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490) - AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) - `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) diff --git a/prowler/config/config.py b/prowler/config/config.py index 10e63c42df..71c59d82a3 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -144,8 +144,7 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) - # External compliance via entry points. - # Multi-provider support for external plug-ins is tracked in PROWLER-1444. + # External per-provider compliance via entry points. ep_dirs = _get_ep_compliance_dirs() for prov, path in ep_dirs.items(): if provider and prov != provider: @@ -156,6 +155,32 @@ def get_available_compliance_frameworks(provider=None): name = file.name.removesuffix(".json") if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) + # External multi-provider frameworks via the dedicated universal group; + # filtered by supports_provider when a provider is given. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + path = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not os.path.isdir(path): + continue + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if provider: + framework = load_compliance_framework_universal(file.path) + if framework is None or not framework.supports_provider(provider): + continue + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) return available_compliance_frameworks diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 8cc588cc4c..3610893008 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -478,9 +478,15 @@ class Compliance(BaseModel): compliance_framework_name not in bulk_compliance_frameworks ): - bulk_compliance_frameworks[ - compliance_framework_name - ] = load_compliance_framework(file_path) + # External JSON: tolerate non-legacy + # schemas (skip + warn) instead of aborting. + framework = load_compliance_framework( + file_path, fatal=False + ) + if framework is not None: + bulk_compliance_frameworks[ + compliance_framework_name + ] = framework except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -494,18 +500,26 @@ class Compliance(BaseModel): # Testing Pending def load_compliance_framework( - compliance_specification_file: str, -) -> Compliance: - """load_compliance_framework loads and parse a Compliance Framework Specification""" + compliance_specification_file: str, fatal: bool = True +) -> Optional[Compliance]: + """load_compliance_framework loads and parse a Compliance Framework Specification. + + With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with + ``fatal=False`` (external JSONs) it is skipped with a warning and ``None`` + is returned. + """ try: - compliance_framework = Compliance.parse_file(compliance_specification_file) + return Compliance.parse_file(compliance_specification_file) except ValidationError as error: - logger.critical( - f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + if fatal: + logger.critical( + f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + ) + sys.exit(1) + logger.warning( + f"Skipping invalid compliance framework {compliance_specification_file}: {error}" ) - sys.exit(1) - else: - return compliance_framework + return None # ─── Universal Compliance Schema Models (Phase 1-3) ───────────────────────── @@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict: if compliance_root and os.path.isdir(compliance_root): _load_jsons_from_dir(compliance_root, provider, bulk) + # External multi-provider frameworks via the dedicated universal entry + # point group, kept separate from the per-provider `prowler.compliance` + # group so the legacy loader never parses a universal JSON. Built-ins + # (already in bulk) win on a name collision. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + ep_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + if os.path.isdir(ep_dir): + _load_jsons_from_dir(ep_dir, provider, bulk) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") return bulk diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py index 5a3e4aae56..c8f614488c 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -1,5 +1,7 @@ import json import os +import tempfile +from unittest.mock import MagicMock, patch import pytest from pydantic.v1 import ValidationError @@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import ( TableLabels, UniversalComplianceRequirement, adapt_legacy_to_universal, + get_bulk_compliance_frameworks_universal, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation: ], attributes_metadata=self._metadata(enum=["high", "low"]), ) + + +class TestGetBulkUniversalEntryPoints: + """Entry-point discovery for universal (multi-provider) compliance frameworks.""" + + @staticmethod + def _write_universal_json(directory, filename, framework, display_name): + data = { + "framework": framework, + "name": display_name, + "version": "1.0", + "description": "External multi-provider framework", + "requirements": [ + { + "id": "1", + "name": "Requirement 1", + "description": "desc", + "checks": {"fakeexternal": ["check_a"]}, + } + ], + } + with open(os.path.join(directory, filename), "w") as f: + json.dump(data, f) + + @staticmethod + def _entry_point(path): + module = MagicMock() + module.__path__ = [path] + ep = MagicMock() + ep.name = "fakeexternal" + ep.group = "prowler.compliance.universal" + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_includes_external_universal_framework(self, mock_list_modules, mock_ep): + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json( + ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + mock_ep.assert_called_with(group="prowler.compliance.universal") + assert "customuniversal_1.0" in bulk + assert bulk["customuniversal_1.0"].framework == "CustomUniversal" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_builtin_wins_over_external_on_name_collision( + self, mock_list_modules, mock_ep + ): + with ( + tempfile.TemporaryDirectory() as root, + tempfile.TemporaryDirectory() as ep_dir, + ): + builtin_sub = os.path.join(root, "builtinprov") + os.makedirs(builtin_sub) + self._write_universal_json( + builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in" + ) + builtin_module = MagicMock() + builtin_module.module_finder.path = root + builtin_module.name = "prowler.compliance.builtinprov" + mock_list_modules.return_value = [builtin_module] + + self._write_universal_json( + ep_dir, "shared_1.0.json", "SharedFramework", "External" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "shared_1.0" in bulk + assert bulk["shared_1.0"].name == "Built-in" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_loads_all_frameworks_in_a_single_entry_point_path( + self, mock_list_modules, mock_ep + ): + """All JSONs in one entry-point directory are added, not collapsed to one.""" + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A") + self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B") + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "fw_a_1.0" in bulk + assert "fw_b_1.0" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_merges_frameworks_from_multiple_packages_same_provider( + self, mock_list_modules, mock_ep + ): + """Two packages under the same provider name are both discovered.""" + mock_list_modules.return_value = [] + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A") + self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B") + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "pkg_a_1.0" in bulk + assert "pkg_b_1.0" in bulk diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index e60720e32e..eefde19545 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1218,6 +1218,48 @@ class TestCompliance: assert "custom_1.0_ext" in frameworks + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_available_compliance_includes_external_universal(self, mock_ep): + """External universal frameworks under prowler.compliance.universal are + listed, for a provider and for the provider=None case that feeds + --compliance choices.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + with tempfile.TemporaryDirectory() as tmpdir: + framework = { + "framework": "CustomUniversal", + "name": "Custom Universal", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f: + json.dump(framework, f) + + module = MagicMock() + module.__path__ = [tmpdir] + ep = _make_entry_point( + "anyname", "pkg.compliance", "prowler.compliance.universal" + ) + ep.load.return_value = module + mock_ep.side_effect = lambda group: ( + [ep] if group == "prowler.compliance.universal" else [] + ) + + assert "customuniversal_1.0" in get_available_compliance_frameworks("aws") + assert "customuniversal_1.0" in get_available_compliance_frameworks(None) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") @patch("prowler.lib.check.compliance_models.list_compliance_modules") def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep): @@ -1257,6 +1299,49 @@ class TestCompliance: assert "custom_1.0_fakeexternal" in bulk assert bulk["custom_1.0_fakeexternal"].Framework == "Custom" + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_skips_non_legacy_external_json( + self, mock_list_modules, mock_ep + ): + """A universal-schema JSON registered under prowler.compliance is skipped, + not aborting the run via sys.exit.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "framework": "Universal", + "name": "Universal Framework", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("aws") + + assert "universal_1.0" not in bulk + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") @patch("prowler.lib.check.compliance_models.list_compliance_modules") def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep): From b994b0b14e430f4c78364633dee7fd1fa72fe776 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 9 Jun 2026 13:53:21 +0200 Subject: [PATCH 029/129] chore(ui): rename customer support to support desk (#11508) --- ui/CHANGELOG.md | 4 ++++ ui/components/ui/sidebar/submenu-item.tsx | 16 ++++++------- ui/lib/menu-list.ts | 29 +++++++++++++---------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index cbcc66fe7d..49e6ddf9f0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file. - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +### 🔄 Changed + +- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) + --- ## [1.29.3] (Prowler v5.29.3) diff --git a/ui/components/ui/sidebar/submenu-item.tsx b/ui/components/ui/sidebar/submenu-item.tsx index 1b0b0aeaf3..2767381ff0 100644 --- a/ui/components/ui/sidebar/submenu-item.tsx +++ b/ui/components/ui/sidebar/submenu-item.tsx @@ -45,13 +45,13 @@ export const SubmenuItem = ({ @@ -75,7 +75,7 @@ export const SubmenuItem = ({ >
diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index c46f061188..25823038d5 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -41,7 +41,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} @@ -49,7 +49,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} diff --git a/ui/components/compliance/compliance-custom-details/csa-details.tsx b/ui/components/compliance/compliance-custom-details/csa-details.tsx index 738e4a8aab..81dbcc158a 100644 --- a/ui/components/compliance/compliance-custom-details/csa-details.tsx +++ b/ui/components/compliance/compliance-custom-details/csa-details.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib"; +import { Badge } from "@/components/shadcn/badge/badge"; import { CSA_MAPPING_SECTIONS } from "@/lib/compliance/csa"; import { Requirement } from "@/types/compliance"; @@ -36,28 +36,28 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => { )} {requirement.iaas && ( )} {requirement.paas && ( )} {requirement.saas && ( )} @@ -72,15 +72,9 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => {
{mapping.Identifiers.map((identifier, idx) => ( - + {identifier} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/dora-details.tsx b/ui/components/compliance/compliance-custom-details/dora-details.tsx index 3d84e891ba..f27412be5b 100644 --- a/ui/components/compliance/compliance-custom-details/dora-details.tsx +++ b/ui/components/compliance/compliance-custom-details/dora-details.tsx @@ -26,21 +26,21 @@ export const DORACustomDetails = ({ requirement }: DORADetailsProps) => { )} {requirement.article && ( )} {requirement.article_title && ( )} diff --git a/ui/components/compliance/compliance-custom-details/ens-details.tsx b/ui/components/compliance/compliance-custom-details/ens-details.tsx index 9e947cea14..2950c495cd 100644 --- a/ui/components/compliance/compliance-custom-details/ens-details.tsx +++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx @@ -28,7 +28,7 @@ export const ENSCustomDetails = ({ )} @@ -36,7 +36,7 @@ export const ENSCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/generic-details.tsx b/ui/components/compliance/compliance-custom-details/generic-details.tsx index caa3fec05d..323e3d9a99 100644 --- a/ui/components/compliance/compliance-custom-details/generic-details.tsx +++ b/ui/components/compliance/compliance-custom-details/generic-details.tsx @@ -26,7 +26,7 @@ export const GenericCustomDetails = ({ )} @@ -34,7 +34,7 @@ export const GenericCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const GenericCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/mitre-details.tsx b/ui/components/compliance/compliance-custom-details/mitre-details.tsx index 8fa7fa276d..53fc583f2c 100644 --- a/ui/components/compliance/compliance-custom-details/mitre-details.tsx +++ b/ui/components/compliance/compliance-custom-details/mitre-details.tsx @@ -37,7 +37,7 @@ export const MITRECustomDetails = ({ )} @@ -81,17 +81,17 @@ export const MITRECustomDetails = ({
{service.comment && ( diff --git a/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx new file mode 100644 index 0000000000..0869b7f390 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx @@ -0,0 +1,65 @@ +import { Severity, SeverityBadge } from "@/components/ui/table"; +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceChipContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const OktaIDaaSStigCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + const severity = requirement.severity as string | undefined; + const stigId = requirement.stig_id as string | undefined; + const ruleId = requirement.rule_id as string | undefined; + const cci = requirement.cci as string[] | undefined; + const checkText = requirement.check_text as string | undefined; + const fixText = requirement.fix_text as string | undefined; + + return ( + + + {severity && ( +
+ + Severity: + + +
+ )} + {stigId && ( + + )} + {ruleId && ( + + )} +
+ + {requirement.description && ( + + {requirement.description} + + )} + + + + {checkText && ( + + {checkText} + + )} + + {fixText && ( + + {fixText} + + )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/shared-components.tsx b/ui/components/compliance/compliance-custom-details/shared-components.tsx index d5c69499af..22ba330d57 100644 --- a/ui/components/compliance/compliance-custom-details/shared-components.tsx +++ b/ui/components/compliance/compliance-custom-details/shared-components.tsx @@ -1,4 +1,12 @@ -import { cn } from "@/lib/utils"; +import { VariantProps } from "class-variance-authority"; + +import { Badge, badgeVariants } from "@/components/shadcn/badge/badge"; + +// Variants come straight from the canonical shadcn Badge so compliance panels +// share the same badge vocabulary (and tokens) as the rest of the app. +export type ComplianceBadgeVariant = NonNullable< + VariantProps["variant"] +>; export const ComplianceDetailContainer = ({ children, @@ -43,55 +51,28 @@ export const ComplianceBadgeContainer = ({ return
{children}
; }; -type BadgeColor = - | "red" // Risk/Level/Severity - | "blue" // Assessment/Method - | "orange" // Type/Category - | "green" // Weight/Score (positive) - | "purple" // Profile - | "indigo" // IDs/References - | "gray"; // Additional Info/Neutral - export const ComplianceBadge = ({ label, value, - color, + variant, conditional = false, }: { label: string; value: string | number; - color: BadgeColor; + variant: ComplianceBadgeVariant; conditional?: boolean; }) => { - const actualColor = conditional && Number(value) === 0 ? "gray" : color; - - const colorClasses = { - red: "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", - blue: "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", - orange: - "bg-orange-50 text-orange-700 ring-orange-600/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20", - green: - "bg-green-50 text-green-700 ring-green-600/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20", - purple: - "bg-purple-50 text-purple-700 ring-purple-600/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20", - indigo: - "bg-indigo-50 text-indigo-700 ring-indigo-600/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/20", - gray: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20", - }; + // A "conditional" metric badge with a zero value drops to a neutral variant + // so empty scores don't read as a meaningful (e.g. positive) result. + const actualVariant: ComplianceBadgeVariant = + conditional && Number(value) === 0 ? "secondary" : variant; return (
{label}: - - {value} - + {value}
); }; @@ -132,12 +113,9 @@ export const ComplianceChipContainer = ({
{items.map((item: string, index: number) => ( - + {item} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx index 822612234d..542583ff0e 100644 --- a/ui/components/compliance/compliance-custom-details/threat-details.tsx +++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx @@ -34,7 +34,7 @@ export const ThreatCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const ThreatCustomDetails = ({ )} @@ -50,7 +50,7 @@ export const ThreatCustomDetails = ({ )} @@ -61,13 +61,13 @@ export const ThreatCustomDetails = ({ {requirement.totalFindings > 0 && ( )} diff --git a/ui/components/icons/compliance/IconCompliance.test.ts b/ui/components/icons/compliance/IconCompliance.test.ts index ca5f5a363e..b956b80548 100644 --- a/ui/components/icons/compliance/IconCompliance.test.ts +++ b/ui/components/icons/compliance/IconCompliance.test.ts @@ -58,6 +58,12 @@ describe("getComplianceIcon", () => { expect(getComplianceIcon("prowler_threatscore_aws")).toBe(threatLogo); }); + it("resolves the Okta IDaaS STIG via the `okta` keyword", () => { + const oktaLogo = getComplianceIcon("Okta-IDaaS-STIG"); + expect(oktaLogo).toBeDefined(); + expect(getComplianceIcon("okta_idaas_stig_v1r2_okta")).toBe(oktaLogo); + }); + it("resolves ASD Essential Eight by the framework keyword, not by `aws`", () => { const essentialLogo = getComplianceIcon("ASD-Essential-Eight"); expect(essentialLogo).toBeDefined(); diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index fe493e2096..ab0fb9a36a 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -18,6 +18,7 @@ import KISALogo from "./kisa.svg"; import MITRELogo from "./mitre-attack.svg"; import NIS2Logo from "./nis2.svg"; import NISTLogo from "./nist.svg"; +import OktaLogo from "./okta.svg"; import PCILogo from "./pci-dss.svg"; import PROWLERTHREATLogo from "./prowlerThreat.svg"; import RBILogo from "./rbi.svg"; @@ -72,6 +73,7 @@ const COMPLIANCE_LOGOS = [ // compliance_id is just `dora`, no provider suffix. ["dora", DORALogo], ["secnumcloud", ANSSILogo], + ["okta", OktaLogo], ["aws", AWSLogo], ] as const; diff --git a/ui/components/icons/compliance/okta.svg b/ui/components/icons/compliance/okta.svg new file mode 100644 index 0000000000..91b8acbb20 --- /dev/null +++ b/ui/components/icons/compliance/okta.svg @@ -0,0 +1 @@ + diff --git a/ui/components/shadcn/badge/badge.test.tsx b/ui/components/shadcn/badge/badge.test.tsx new file mode 100644 index 0000000000..adae2f54b8 --- /dev/null +++ b/ui/components/shadcn/badge/badge.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Badge } from "./badge"; + +describe("Badge", () => { + it("renders its children", () => { + render(Assessment); + expect(screen.getByText("Assessment")).toBeInTheDocument(); + }); + + it("applies the info variant token classes", () => { + const { container } = render(Info); + const badge = container.querySelector("[data-slot='badge']"); + // The info variant is built from the existing design-system blue token + // (bg-data-info) rather than a bespoke palette. + expect(badge?.className).toContain("bg-bg-data-info/15"); + expect(badge?.className).toContain("text-bg-data-info"); + }); + + it("merges a custom className", () => { + const { container } = render( + + Tag + , + ); + const badge = container.querySelector("[data-slot='badge']"); + expect(badge?.className).toContain("extra-class"); + }); +}); diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index f200fda7e2..fd1a6efd11 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -24,6 +24,7 @@ const badgeVariants = cva( "border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary", error: "border-transparent bg-bg-fail-secondary text-text-error-primary", + info: "border-transparent bg-bg-data-info/15 text-bg-data-info", }, }, defaultVariants: { diff --git a/ui/lib/compliance/ccc.tsx b/ui/lib/compliance/ccc.tsx index fc6f7e898a..59df6a7c09 100644 --- a/ui/lib/compliance/ccc.tsx +++ b/ui/lib/compliance/ccc.tsx @@ -1,6 +1,7 @@ import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { @@ -39,7 +40,7 @@ export interface CCCTextSection { export interface CCCMappingSection { title: string; key: keyof Requirement; - colorClasses: string; + variant: ComplianceBadgeVariant; } export const CCC_TEXT_SECTIONS: CCCTextSection[] = [ @@ -71,14 +72,12 @@ export const CCC_MAPPING_SECTIONS: CCCMappingSection[] = [ { title: "Threat Mappings", key: "section_threat_mappings", - colorClasses: - "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", + variant: "error", }, { title: "Guideline Mappings", key: "section_guideline_mappings", - colorClasses: - "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", + variant: "info", }, ]; diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts index 9526b50b07..25933b8dff 100644 --- a/ui/lib/compliance/compliance-mapper.test.ts +++ b/ui/lib/compliance/compliance-mapper.test.ts @@ -62,6 +62,10 @@ vi.mock( "@/components/compliance/compliance-custom-details/mitre-details", () => ({ MITRECustomDetails: stubFactory("MITREStub") }), ); +vi.mock( + "@/components/compliance/compliance-custom-details/okta-idaas-stig-details", + () => ({ OktaIDaaSStigCustomDetails: stubFactory("OktaIDaaSStigStub") }), +); vi.mock( "@/components/compliance/compliance-custom-details/threat-details", () => ({ ThreatCustomDetails: stubFactory("ThreatStub") }), @@ -144,6 +148,7 @@ describe("getComplianceMapper", () => { { framework: "ProwlerThreatScore", expected: "ThreatStub" }, { framework: "CCC", expected: "CCCStub" }, { framework: "CSA-CCM", expected: "CSAStub" }, + { framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" }, ]; for (const { framework, expected } of wiring) { @@ -188,6 +193,7 @@ describe("getComplianceMapper", () => { "ProwlerThreatScore", "CCC", "CSA-CCM", + "Okta-IDaaS-STIG", ]) { const mapper = getComplianceMapper(framework); expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys); diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index ba8c97ecfc..2e44958c73 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -12,6 +12,7 @@ import { GenericCustomDetails } from "@/components/compliance/compliance-custom- import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details"; import { MITRECustomDetails } from "@/components/compliance/compliance-custom-details/mitre-details"; +import { OktaIDaaSStigCustomDetails } from "@/components/compliance/compliance-custom-details/okta-idaas-stig-details"; import { ThreatCustomDetails } from "@/components/compliance/compliance-custom-details/threat-details"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { @@ -74,6 +75,10 @@ import { mapComplianceData as mapMITREComplianceData, toAccordionItems as toMITREAccordionItems, } from "./mitre"; +import { + mapComplianceData as mapOktaIDaaSStigComplianceData, + toAccordionItems as toOktaIDaaSStigAccordionItems, +} from "./okta-idaas-stig"; import { getTopFailedSections as getThreatScoreTopFailedSections, mapComplianceData as mapThetaComplianceData, @@ -213,6 +218,15 @@ const getComplianceMappers = (): Record => ({ getDetailsComponent: (requirement: Requirement) => createElement(CSACustomDetails, { requirement }), }, + "Okta-IDaaS-STIG": { + mapComplianceData: mapOktaIDaaSStigComplianceData, + toAccordionItems: toOktaIDaaSStigAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(OktaIDaaSStigCustomDetails, { requirement }), + }, // DORA (Regulation (EU) 2022/2554) — universal framework keyed by the // `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by // Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx index 93974d6b47..c4624baa43 100644 --- a/ui/lib/compliance/csa.tsx +++ b/ui/lib/compliance/csa.tsx @@ -1,6 +1,7 @@ import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { FindingStatus } from "@/components/ui/table/status-finding-badge"; import { @@ -24,15 +25,14 @@ import { export interface CSAMappingSection { title: string; key: keyof Requirement; - colorClasses: string; + variant: ComplianceBadgeVariant; } export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [ { title: "Scope Applicability", key: "scope_applicability", - colorClasses: - "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", + variant: "info", }, ]; diff --git a/ui/lib/compliance/okta-idaas-stig.tsx b/ui/lib/compliance/okta-idaas-stig.tsx new file mode 100644 index 0000000000..5a00886406 --- /dev/null +++ b/ui/lib/compliance/okta-idaas-stig.tsx @@ -0,0 +1,163 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Control, + Framework, + isOktaIDaaSStigAttributesMetadata, + OktaIDaaSStigRequirement, + Requirement, + REQUIREMENT_STATUS, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +import { + calculateFrameworkCounters, + createRequirementsMap, + findOrCreateCategory, + findOrCreateControl, + findOrCreateFramework, +} from "./commons"; + +const getStatusCounters = (status: RequirementStatus) => ({ + pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, + fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, + manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, +}); + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; + + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes?.metadata; + const attrs = metadataArray?.[0]; + if (!isOktaIDaaSStigAttributesMetadata(attrs)) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + // Level 1: Section maps to the STIG severity category (e.g. "CAT II (Medium)") + const categoryName = attrs.Section; + // Level 2: each requirement is its own control, labelled by its STIG ID + const controlLabel = id; + const description = attributeItem.attributes.description; + // Human-readable STIG title (e.g. "Okta must log out a session after a + // 15-minute period of inactivity."). Surface it next to the STIG ID and + // fall back to the bare ID when missing, mirroring DORA/CSA. + const requirementName = attributeItem.attributes.name || ""; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + + const framework = findOrCreateFramework(frameworks, frameworkName); + const category = findOrCreateCategory(framework.categories, categoryName); + const control = findOrCreateControl(category.controls, controlLabel); + + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement = { + name: requirementName ? `${id} - ${requirementName}` : id, + description, + status: finalStatus, + check_ids: checks, + ...getStatusCounters(finalStatus), + severity: attrs.Severity, + rule_id: attrs.RuleID, + stig_id: attrs.StigID, + cci: attrs.CCI, + check_text: attrs.CheckText, + fix_text: attrs.FixText, + } satisfies OktaIDaaSStigRequirement; + + control.requirements.push(requirement); + } + + calculateFrameworkCounters(frameworks); + + return frameworks; +}; + +const createRequirementItem = ( + requirement: Requirement, + frameworkName: string, + categoryName: string, + controlIndex: number, + scanId: string, +): AccordionItemProps => ({ + key: `${frameworkName}-${categoryName}-control-${controlIndex}`, + title: ( + + ), + content: ( + + ), + items: [], +}); + +const createControlItem = ( + control: Control, + frameworkName: string, + categoryName: string, + controlIndex: number, + scanId: string, +): AccordionItemProps => + createRequirementItem( + control.requirements[0], + frameworkName, + categoryName, + controlIndex, + scanId, + ); + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + const safeId = scanId || ""; + + return data.flatMap((framework) => + framework.categories.map((category) => ({ + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, controlIndex) => + createControlItem( + control, + framework.name, + category.name, + controlIndex, + safeId, + ), + ), + })), + ); +}; diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index e926efe356..fde9d260e9 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -279,6 +279,12 @@ const isOneOf = ( const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); +const isOptionalString = (value: unknown): value is string | undefined => + value === undefined || typeof value === "string"; + +const isOptionalStringArray = (value: unknown): value is string[] | undefined => + value === undefined || isStringArray(value); + const ASD_METADATA_STRING_FIELDS = [ "Section", "Description", @@ -327,6 +333,43 @@ export interface ASDEssentialEightRequirement extends Requirement { references: ASDEssentialEightAttributesMetadata["References"]; } +export interface OktaIDaaSStigAttributesMetadata { + Section: string; + Severity: string; + RuleID: string; + StigID: string; + CCI?: string[]; + CheckText?: string; + FixText?: string; +} + +const OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS = [ + "Section", + "Severity", + "RuleID", + "StigID", +] as const satisfies readonly (keyof OktaIDaaSStigAttributesMetadata)[]; + +export const isOktaIDaaSStigAttributesMetadata = ( + value: unknown, +): value is OktaIDaaSStigAttributesMetadata => + isRecord(value) && + OKTA_IDAAS_STIG_REQUIRED_STRING_FIELDS.every( + (field) => typeof value[field] === "string", + ) && + isOptionalStringArray(value.CCI) && + isOptionalString(value.CheckText) && + isOptionalString(value.FixText); + +export interface OktaIDaaSStigRequirement extends Requirement { + severity: OktaIDaaSStigAttributesMetadata["Severity"]; + rule_id: OktaIDaaSStigAttributesMetadata["RuleID"]; + stig_id: OktaIDaaSStigAttributesMetadata["StigID"]; + cci: OktaIDaaSStigAttributesMetadata["CCI"]; + check_text: OktaIDaaSStigAttributesMetadata["CheckText"]; + fix_text: OktaIDaaSStigAttributesMetadata["FixText"]; +} + // DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554). // Universal framework — flat attributes dict with Pillar/Article/ArticleTitle. // `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors @@ -374,6 +417,7 @@ export interface AttributesItemData { | CCCAttributesMetadata[] | CSAAttributesMetadata[] | ASDEssentialEightAttributesMetadata[] + | OktaIDaaSStigAttributesMetadata[] | DORAAttributesMetadata[] | GenericAttributesMetadata[]; check_ids: string[]; From bfb3fcea4c02e9cb8fd488bb5a8089b49d9617f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 11:34:35 +0200 Subject: [PATCH 040/129] fix(e2e): use branch SDK changes to create the container (#11522) --- .github/workflows/ui-e2e-tests-v2.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 5e50adb19c..1a3653f8c9 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -134,7 +134,17 @@ jobs: # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, # which lags behind PR changes; build locally so E2E exercises the API image # produced by this PR. - run: docker build -t prowlercloud/prowler-api:latest ./api + # + # The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK + # and the API would run against the OLD SDK and crash on startup. Overlay the checkout's + # SDK source so both run together. New SDK dependencies still need an api/uv.lock bump. + run: | + docker build -t prowlercloud/prowler-api:pr-base ./api + docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE' + FROM prowlercloud/prowler-api:pr-base + RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler + COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler + DOCKERFILE - name: Start API services run: | From ec0bb5383975c6fc5d4c9bdf26231d0d00f8deca Mon Sep 17 00:00:00 2001 From: Aryan Bhaskar <115079796+ARYAN03B@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:10:54 +0530 Subject: [PATCH 041/129] feat(bedrock): add bedrock_agent_role_least_privilege check (#11335) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...k_agent_role_least_privilege.metadata.json | 41 +++ .../bedrock_agent_role_least_privilege.py | 101 +++++++ .../aws/services/bedrock/bedrock_service.py | 18 ++ .../providers/aws/services/iam/iam_service.py | 33 +++ ...bedrock_agent_role_least_privilege_test.py | 275 ++++++++++++++++++ 7 files changed, 469 insertions(+) create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json create mode 100644 prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py create mode 100644 tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c9542d8345..056ae34f89 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) - `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) +- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) ### 🐞 Fixed diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json new file mode 100644 index 0000000000..7625b6a626 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_least_privilege", + "CheckTitle": "Amazon Bedrock agent execution role follows least privilege", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Privilege Escalation" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.", + "Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam put-role-permissions-boundary --role-name --permissions-boundary ", + "NativeIaC": "", + "Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes", + "Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py new file mode 100644 index 0000000000..e53183d90f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_admin_access +from prowler.providers.aws.services.iam.lib.privilege_escalation import ( + check_privilege_escalation, +) + + +class bedrock_agent_role_least_privilege(Check): + """Ensure Bedrock Agent execution roles follow least privilege. + + A Bedrock Agent's execution role is evaluated against three criteria: + - No AWS-managed ``*FullAccess`` policy attached. + - No attached or inline policy granting administrative access or known + privilege escalation combinations. + - A permissions boundary is configured on the role. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Run the least-privilege evaluation across all Bedrock Agents. + + Returns: + A list of ``Check_Report_AWS`` with one entry per agent. The + status is ``FAIL`` when any of the criteria above is violated, + or when the execution role cannot be resolved in IAM. + """ + findings = [] + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + report.status = "PASS" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role follows least privilege." + ) + + role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None + if role is None: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role could not be " + f"resolved in IAM and cannot be evaluated for least privilege." + ) + findings.append(report) + continue + + violations = [] + + for policy in role.attached_policies: + policy_arn = policy.get("PolicyArn", "") + policy_name = policy.get("PolicyName") or policy_arn + if policy_arn.startswith( + "arn:aws:iam::aws:policy/" + ) and policy_arn.endswith("FullAccess"): + violations.append( + f"managed policy {policy_name} grants full access" + ) + continue + policy_obj = iam_client.policies.get(policy_arn) + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"managed policy {policy_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"managed policy {policy_name} allows privilege escalation" + ) + + for inline_name in role.inline_policies: + policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}") + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"inline policy {inline_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"inline policy {inline_name} allows privilege escalation" + ) + + if not role.permissions_boundary: + violations.append("no permissions boundary configured") + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role violates least " + f"privilege: {'; '.join(violations)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index 7222456341..aead63a67f 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -146,6 +146,7 @@ class BedrockAgent(AWSService): self.prompts = {} self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) + self.__threading_call__(self._get_agent, self.agents.values()) self.__threading_call__(self._list_prompts) self.__threading_call__(self._get_prompt, self.prompts.values()) self.__threading_call__(self._list_tags_for_resource, self.agents.values()) @@ -174,6 +175,22 @@ class BedrockAgent(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_agent(self, agent): + """Fetch full agent details to capture the execution role ARN. + + list_agents only returns summaries (no agentResourceRoleArn), so we + need a per-agent GetAgent call. Stored on the Agent model for use by + checks like bedrock_agent_role_least_privilege. + """ + logger.info("Bedrock Agent - Getting Agent...") + try: + agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) + agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + except Exception as error: + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_prompts(self, regional_client): """List all prompts in a region.""" logger.info("Bedrock Agent - Listing Prompts...") @@ -236,6 +253,7 @@ class Agent(BaseModel): name: str arn: str guardrail_id: Optional[str] = None + role_arn: Optional[str] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index d6cb150178..fa01550b11 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -103,6 +103,9 @@ class IAM(AWSService): self._get_user_temporary_credentials_usage() self.organization_features = [] self._list_organizations_features() + # ListRoles does not echo PermissionsBoundary; backfill via GetRole. + if self.roles: + self.__threading_call__(self._get_role_permissions_boundary, self.roles) # List missing tags self.__threading_call__(self._list_tags, self.users) self.__threading_call__(self._list_tags, self.roles) @@ -133,6 +136,7 @@ class IAM(AWSService): arn=role["Arn"], assume_role_policy=role["AssumeRolePolicyDocument"], is_service_role=is_service_role(role), + permissions_boundary=role.get("PermissionsBoundary"), ) ) except ClientError as error: @@ -460,6 +464,34 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_permissions_boundary(self, role): + """Backfill ``role.permissions_boundary`` via ``GetRole``. + + ``ListRoles`` does not return ``PermissionsBoundary`` in practice, so + the value is fetched per role and stored on the ``Role`` model. + + Args: + role: The ``Role`` instance to enrich. + """ + try: + response = self.client.get_role(RoleName=role.name) + role.permissions_boundary = response.get("Role", {}).get( + "PermissionsBoundary" + ) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_attached_role_policies(self): logger.info("IAM - List Attached Role Policies...") try: @@ -1139,6 +1171,7 @@ class Role(BaseModel): is_service_role: bool attached_policies: list[dict] = [] inline_policies: list[str] = [] + permissions_boundary: Optional[dict] = None tags: Optional[list] diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py new file mode 100644 index 0000000000..c6e5d7f756 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -0,0 +1,275 @@ +from json import dumps +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +AGENT_ID = "test-agent-id" +AGENT_NAME = "test-agent-name" +AGENT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}" +) +ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}" +BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +BOUNDARY_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}], +} + +NARROW_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-rag-bucket/*"], + } + ], +} + +BROAD_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent +# support is incomplete for our needs (GetAgent often doesn't echo back the +# role ARN we set), so we control the responses directly. We also need to keep +# IAM calls going to moto. +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_bedrock_agent_factory(role_arn): + """Return a mock_make_api_call function that returns role_arn from GetAgent. + + Pass role_arn=None to simulate an agent whose role can't be resolved. + """ + + def _mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListAgents": + return { + "agentSummaries": [ + {"agentId": AGENT_ID, "agentName": AGENT_NAME}, + ] + } + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_ID, + "agentName": AGENT_NAME, + "agentResourceRoleArn": role_arn, + } + } + if operation_name == "ListTagsForResource": + return {"tags": {}} + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + return _mock_make_api_call + + +def _setup_role( + *, + attached_policy_arns=(), + inline_policies=None, + permissions_boundary=None, +): + """Create an IAM role in moto with the given configuration. Returns the role ARN.""" + iam = client("iam", region_name=AWS_REGION_US_EAST_1) + + if permissions_boundary: + iam.create_policy( + PolicyName="AgentBoundary", + PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT), + ) + + create_kwargs = { + "RoleName": ROLE_NAME, + "AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT), + } + if permissions_boundary: + create_kwargs["PermissionsBoundary"] = permissions_boundary + iam.create_role(**create_kwargs) + + for policy_arn in attached_policy_arns: + iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn) + + for policy_name, policy_document in (inline_policies or {}).items(): + iam.put_role_policy( + RoleName=ROLE_NAME, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + return ROLE_ARN + + +def _run_check(role_arn_for_get_agent): + """Build the IAM + BedrockAgent services, patch them in, run the check.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_bedrock_agent_factory(role_arn_for_get_agent), + ): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + return bedrock_agent_role_least_privilege().execute() + + +class Test_bedrock_agent_role_least_privilege: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_agents(self): + """No agents in the account -> zero findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + assert bedrock_agent_role_least_privilege().execute() == [] + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_compliant(self): + """Narrow inline policy + boundary + no *FullAccess attached -> PASS.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "follows least privilege" in result[0].status_extended + assert result[0].resource_id == AGENT_ID + assert result[0].resource_arn == AGENT_ARN + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_full_access_attached(self): + """AmazonBedrockFullAccess attached -> FAIL.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants full access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_administrator_access_attached(self): + """AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "managed policy AdministratorAccess grants administrative access" + in result[0].status_extended + ) + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_resource_star_broad_action(self): + """Inline statement with Action:* on Resource:* -> FAIL.""" + role_arn = _setup_role( + inline_policies={"BroadAccess": BROAD_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants administrative access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_no_permissions_boundary(self): + """Otherwise clean role but missing permissions boundary -> FAIL.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=None, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no permissions boundary configured" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_not_resolvable(self): + """role_arn returned by GetAgent doesn't match any IAM role -> FAIL.""" + result = _run_check( + role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist" + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be resolved" in result[0].status_extended From 3c8fde25eeb5c2596b1be4f722be7126bfefee50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 18:19:50 +0200 Subject: [PATCH 042/129] chore(cli): add banner about Prowler Cloud (#11528) --- prowler/__main__.py | 8 ++++++-- prowler/lib/banner.py | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/prowler/__main__.py b/prowler/__main__.py index 703a9e49a0..533a704359 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -19,7 +19,7 @@ from prowler.config.config import ( orange_color, sarif_file_suffix, ) -from prowler.lib.banner import print_banner +from prowler.lib.banner import print_banner, print_prowler_cloud_banner from prowler.lib.check.check import ( exclude_checks_to_run, exclude_services_to_run, @@ -202,7 +202,7 @@ def prowler(): if not args.no_banner: legend = args.verbose or getattr(args, "fixer", None) - print_banner(legend) + print_banner(legend, provider) # We treat the compliance framework as another output format if compliance_framework: @@ -1476,6 +1476,10 @@ def prowler(): f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n" ) + # Promote Prowler Cloud as the last thing the user sees after the results + if not args.no_banner and not args.only_logs: + print_prowler_cloud_banner(provider) + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 72031805e2..bb693fb961 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -3,12 +3,13 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp -def print_banner(legend: bool = False): +def print_banner(legend: bool = False, provider: str = None): """ Prints the banner with optional legend for color codes. Parameters: - legend (bool): Flag to indicate whether to print the color legend or not. Default is False. + - provider (str): The provider being scanned, used to tailor the Prowler Cloud banner. Returns: - None @@ -20,13 +21,12 @@ def print_banner(legend: bool = False): | .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} |_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} -{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL} -{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL} - {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ print(banner) + print_prowler_cloud_banner(provider) + if legend: print( f""" @@ -37,3 +37,37 @@ def print_banner(legend: bool = False): - {Fore.RED}FAIL (Fix required){Style.RESET_ALL} """ ) + + +def print_prowler_cloud_banner(provider: str = None): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the start and end of a scan to let users know about the managed + platform capabilities they are missing (attack paths, AI, organizations, + continuous scanning, integrations and live compliance dashboards). + + Parameters: + - provider (str): The provider that was scanned, used to tailor the message. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print( + f""" +{bar} {Style.BRIGHT}You're getting a snapshot. Prowler Cloud gives you the full picture.{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, prioritization and remediation +{bar} {check} {Style.BRIGHT}Organizations{Style.RESET_ALL} - all your AWS accounts under one organization +{bar} {check} {Style.BRIGHT}Continuous scanning{Style.RESET_ALL} - scheduled scans with history, trends and alerts +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC +{bar} {check} {Style.BRIGHT}Reports{Style.RESET_ALL} - download ready-to-share PDF reports +{bar} {check} {Style.BRIGHT}Live compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date +{bar} +{bar} {Fore.BLUE}Start free at cloud.prowler.com{Style.RESET_ALL} +""" + ) From 368d3a2661cb8b08e081caf3c1d3a0a5253314d9 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 10 Jun 2026 18:43:24 +0200 Subject: [PATCH 043/129] feat(stackit): add objectstorage checks (#11397) Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- README.md | 2 +- prowler/CHANGELOG.md | 1 + .../services/objectstorage/__init__.py | 0 .../__init__.py | 0 ...torage_access_key_expiration.metadata.json | 37 + .../objectstorage_access_key_expiration.py | 27 + .../__init__.py | 0 ...e_bucket_object_lock_enabled.metadata.json | 37 + ...bjectstorage_bucket_object_lock_enabled.py | 31 + .../__init__.py | 0 ...rage_bucket_retention_policy.metadata.json | 39 ++ .../objectstorage_bucket_retention_policy.py | 30 + .../objectstorage/objectstorage_client.py | 6 + .../objectstorage/objectstorage_service.py | 306 +++++++++ prowler/providers/stackit/stackit_provider.py | 9 +- .../stackit/stackit_regions_by_service.json | 6 + pyproject.toml | 1 + ...bjectstorage_access_key_expiration_test.py | 150 ++++ ...storage_bucket_object_lock_enabled_test.py | 111 +++ ...ectstorage_bucket_retention_policy_test.py | 153 +++++ .../stackit_objectstorage_service_test.py | 645 ++++++++++++++++++ .../stackit/stackit_provider_test.py | 65 ++ uv.lock | 17 + 23 files changed, 1671 insertions(+), 2 deletions(-) create mode 100644 prowler/providers/stackit/services/objectstorage/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_client.py create mode 100644 prowler/providers/stackit/services/objectstorage/objectstorage_service.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py create mode 100644 tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py create mode 100644 tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py diff --git a/README.md b/README.md index d2808daf84..74e91021ca 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | -| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 056ae34f89..619401bf32 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) - `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) +- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397) ### 🐞 Fixed diff --git a/prowler/providers/stackit/services/objectstorage/__init__.py b/prowler/providers/stackit/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json new file mode 100644 index 0000000000..38da16c5fa --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_access_key_expiration", + "CheckTitle": "ObjectStorage access keys should have an expiration date", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**ObjectStorage access keys** should have an explicit expiration date. Long-lived credentials increase the blast radius of a credential compromise because they cannot expire on their own. Setting an expiration date enforces periodic rotation and limits the exposure window if a key is leaked.", + "Risk": "If an **ObjectStorage access key** is leaked, stolen, or forgotten without an expiration date, it remains usable indefinitely. An attacker can retain persistent access to object storage resources until the key is manually revoked.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/create-and-delete-object-storage-credentials/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal navigate to Object Storage > Access Keys. 2. Delete the non-expiring access key. 3. Create a new access key with an expiration date appropriate for your rotation policy (e.g. 90 days). 4. Update all applications and services that use the old key with the new credentials.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage access keys** with an explicit expiration date and establish a rotation process. Delete non-expiring keys and replace them with time-limited credentials. A rotation period of **90 days or less** is recommended.", + "Url": "https://hub.prowler.com/check/objectstorage_access_key_expiration" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Access keys are scoped to credentials groups. This check evaluates all access keys across all credentials groups in the project." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py new file mode 100644 index 0000000000..2fb49ac725 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_access_key_expiration(Check): + def execute(self): + findings = [] + for key in objectstorage_client.access_keys: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=key, + ) + report.resource_id = key.key_id + report.resource_name = key.display_name + report.location = key.region + + if key.has_expiration(): + report.status = "PASS" + report.status_extended = f"Access key {key.display_name} has an expiration date set ({key.expires})." + else: + report.status = "FAIL" + report.status_extended = f"Access key {key.display_name} has no expiration date and never rotates." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json new file mode 100644 index 0000000000..cd587c72da --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_object_lock_enabled", + "CheckTitle": "ObjectStorage buckets should have S3 Object Lock enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**S3 Object Lock** prevents objects from being deleted or overwritten for a fixed period or indefinitely. Enabling it protects against accidental deletion and ransomware by enforcing a **write-once-read-many (WORM)** model. Object Lock can only be enabled when the bucket is created.", + "Risk": "Without **Object Lock**, objects can be deleted or overwritten at any time, increasing the risk of data loss from accidental deletion, malicious actors, or ransomware. Backups and compliance data are particularly vulnerable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-bucket/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Object Lock must be enabled at bucket creation time and cannot be enabled on an existing bucket. Create a new bucket with Object Lock enabled and migrate your data to it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage buckets** with S3 Object Lock enabled for workloads that require data immutability, compliance archiving, or ransomware protection. Object Lock cannot be retroactively enabled on existing buckets.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_object_lock_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Object Lock can only be activated at bucket creation. Buckets without Object Lock are not necessarily misconfigured — evaluate based on the sensitivity and compliance requirements of the stored data." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py new file mode 100644 index 0000000000..89a3c1d3fb --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_object_lock_enabled(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.object_lock_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has S3 Object Lock enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have S3 Object Lock enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json new file mode 100644 index 0000000000..44088e5889 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_retention_policy", + "CheckTitle": "ObjectStorage buckets should have a default retention policy configured", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "An **ObjectStorage default retention policy** automatically applies a minimum retention period to every object uploaded to the bucket, preventing deletion or overwriting before the period expires. Without it, objects can be removed immediately after upload, undermining compliance and data durability requirements.", + "Risk": "Buckets without a **default retention policy** offer no automatic protection against premature object deletion. Compliance data, audit logs, and backups may be deleted before their required retention period elapses.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-default-retention/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Use the STACKIT Object Storage API or Portal to set a default retention policy on the bucket. Choose COMPLIANCE mode for strict immutability or GOVERNANCE mode to allow privileged users to override the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **default retention policy** on every bucket that stores compliance-relevant or sensitive data. Choose `COMPLIANCE` mode for regulatory requirements and `GOVERNANCE` mode when administrative overrides are acceptable.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_retention_policy" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "objectstorage_bucket_object_lock_enabled" + ], + "Notes": "A default retention policy requires Object Lock to be enabled on the bucket. Buckets without Object Lock cannot have a retention policy." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py new file mode 100644 index 0000000000..a9dbc7ed2b --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_retention_policy(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.retention_days and bucket.retention_days > 0: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has a default retention policy of " + f"{bucket.retention_days} day(s) in {bucket.retention_mode} mode." + ) + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} does not have a default retention policy configured." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_client.py b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..56cdbfd0bf --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + ObjectStorageService, +) + +objectstorage_client = ObjectStorageService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_service.py b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..e37545ec24 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py @@ -0,0 +1,306 @@ +import json +from datetime import datetime, timezone +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class ObjectStorageService: + def __init__(self, provider: StackitProvider): + self.provider = provider + self.project_id = provider.identity.project_id + self.regional_clients = provider.generate_regional_clients("objectstorage") + + self.buckets: list[Bucket] = [] + self.access_keys: list[AccessKey] = [] + + self._fetch_all_regions() + + def _fetch_all_regions(self): + for region, client in self.regional_clients.items(): + try: + self._list_buckets(client, region) + self._list_access_keys(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no ObjectStorage " + f"presence in region {region}; skipping." + ) + continue + raise + + def _handle_api_call(self, api_function, *args, **kwargs): + try: + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + self.provider.handle_api_error(e) + raise + + def _list_buckets(self, client, region: str): + response = self._handle_api_call( + client.list_buckets, project_id=self.project_id, region=region + ) + + buckets_list = getattr(response, "buckets", None) or [] + if isinstance(response, dict): + buckets_list = response.get("buckets", []) + + for bucket_data in buckets_list: + try: + if hasattr(bucket_data, "name"): + name = bucket_data.name + object_lock_enabled = getattr( + bucket_data, "object_lock_enabled", False + ) + elif isinstance(bucket_data, dict): + name = bucket_data.get("name", "") + object_lock_enabled = bucket_data.get("objectLockEnabled", False) + else: + continue + except Exception as e: + logger.error(f"Error processing bucket: {e}") + continue + + retention_days, retention_mode = self._get_default_retention( + client, region, name + ) + + self.buckets.append( + Bucket( + name=name, + region=region, + project_id=self.project_id, + object_lock_enabled=object_lock_enabled, + retention_days=retention_days, + retention_mode=retention_mode, + ) + ) + + logger.info(f"Listed {len(buckets_list)} buckets in {region}") + + def _get_default_retention( + self, client, region: str, bucket_name: str + ) -> tuple[Optional[int], Optional[str]]: + try: + response = self._handle_api_call( + client.get_default_retention, + project_id=self.project_id, + region=region, + bucket_name=bucket_name, + ) + days = getattr(response, "days", None) + mode = getattr(response, "mode", None) + if isinstance(response, dict): + days = response.get("days") + mode = response.get("mode") + return days, str(mode) if mode else None + except Exception as e: + if getattr(e, "status", None) == 404: + return None, None + raise + + def _list_access_keys(self, client, region: str): + credentials_groups_response = self._handle_api_call( + client.list_credentials_groups, project_id=self.project_id, region=region + ) + + credentials_groups = ( + getattr(credentials_groups_response, "credentials_groups", None) or [] + ) + if isinstance(credentials_groups_response, dict): + credentials_groups = credentials_groups_response.get( + "credentialsGroups", + credentials_groups_response.get("credentials_groups", []), + ) + + total_keys = 0 + + for credentials_group_data in credentials_groups: + try: + if isinstance(credentials_group_data, dict): + credentials_group_id = credentials_group_data.get( + "id", + credentials_group_data.get( + "groupId", + credentials_group_data.get("credentialsGroupId", ""), + ), + ) + credentials_group_name = credentials_group_data.get( + "displayName", + credentials_group_data.get("name", credentials_group_id), + ) + else: + credentials_group_id = ( + getattr(credentials_group_data, "id", None) + or getattr(credentials_group_data, "group_id", None) + or getattr(credentials_group_data, "credentials_group_id", "") + ) + credentials_group_name = getattr( + credentials_group_data, + "display_name", + getattr(credentials_group_data, "name", credentials_group_id), + ) + except Exception as e: + logger.error(f"Error processing credentials group: {e}") + continue + + if not credentials_group_id: + continue + + response = self._list_access_keys_response( + client, region, credentials_group_id + ) + keys_list = self._extract_access_keys(response) + + for key_data in keys_list: + try: + if hasattr(key_data, "key_id"): + key_id = key_data.key_id + display_name = getattr(key_data, "display_name", key_id) + expires = getattr(key_data, "expires", None) + elif isinstance(key_data, dict): + key_id = key_data.get("keyId", key_data.get("key_id", "")) + display_name = key_data.get( + "displayName", key_data.get("display_name", key_id) + ) + expires = key_data.get("expires") + else: + continue + + if not key_id: + continue + + self.access_keys.append( + AccessKey( + key_id=key_id, + display_name=display_name, + expires=expires, + region=region, + project_id=self.project_id, + credentials_group_id=credentials_group_id, + credentials_group_name=credentials_group_name, + ) + ) + except Exception as e: + logger.error(f"Error processing access key: {e}") + continue + + total_keys += len(keys_list) + + logger.info(f"Listed {total_keys} access keys in {region}") + + def _list_access_keys_response( + self, client, region: str, credentials_group_id: str + ): + raw_method = None + if callable( + getattr(type(client), "list_access_keys_without_preload_content", None) + ): + raw_method = client.list_access_keys_without_preload_content + elif callable(vars(client).get("list_access_keys_without_preload_content")): + raw_method = vars(client)["list_access_keys_without_preload_content"] + + if raw_method: + response = self._handle_api_call( + raw_method, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + self._raise_for_raw_response_status(response) + return response + + return self._handle_api_call( + client.list_access_keys, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + + def _raise_for_raw_response_status(self, response): + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if isinstance(status, int) and status >= 400: + error = Exception( + f"StackIT ObjectStorage list_access_keys failed with status {status}" + ) + error.status = status + self.provider.handle_api_error(error) + raise error + + @staticmethod + def _extract_access_keys(response) -> list: + payload = response + if not isinstance(payload, (dict, list)): + json_method = getattr(response, "json", None) + if callable(json_method): + payload = json_method() + elif hasattr(response, "data"): + payload = ObjectStorageService._parse_raw_json(response.data) + elif hasattr(response, "text"): + payload = ObjectStorageService._parse_raw_json(response.text) + + if isinstance(payload, dict): + return payload.get("accessKeys", payload.get("access_keys", [])) + if isinstance(payload, list): + return payload + return getattr(response, "access_keys", None) or [] + + @staticmethod + def _parse_raw_json(raw): + if raw in (None, b"", ""): + return {} + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + return json.loads(raw) + return raw + + +class Bucket(BaseModel): + name: str + region: str + project_id: str + object_lock_enabled: bool = False + retention_days: Optional[int] = None + retention_mode: Optional[str] = None + + +class AccessKey(BaseModel): + key_id: str + display_name: str + # None or a sentinel year-0001 date string means the key never expires. + expires: Optional[str] = None + region: str + project_id: str + credentials_group_id: Optional[str] = None + credentials_group_name: Optional[str] = None + + def has_expiration(self) -> bool: + """Return True if the key has a real (non-sentinel) expiration date.""" + if not self.expires: + return False + try: + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + # Year 0001 (or earlier) is the SDK sentinel for "never expires" + return dt.year > 1 + except (ValueError, AttributeError): + return False + + def expires_within_days(self, days: int) -> bool: + """Return True if the key expires within the given number of days from now.""" + if not self.has_expiration(): + return False + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = dt - datetime.now(tz=timezone.utc) + return delta.days <= days diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py index 417555ef1d..4c34afd31b 100644 --- a/prowler/providers/stackit/stackit_provider.py +++ b/prowler/providers/stackit/stackit_provider.py @@ -15,6 +15,7 @@ from colorama import Style # loader and surfacing as a misleading empty report. from stackit.core.configuration import Configuration from stackit.iaas import DefaultApi as IaasDefaultApi +from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi from prowler.config.config import ( @@ -224,11 +225,17 @@ class StackitProvider(Provider): return json_regions.intersection(audited_regions) return json_regions + _SERVICE_API_CLASS = { + "iaas": IaasDefaultApi, + "objectstorage": ObjectStorageDefaultApi, + } + def generate_regional_clients(self, service: str = "iaas") -> dict: """Generate regional API clients for the given service. Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} """ + api_class = self._SERVICE_API_CLASS.get(service, IaasDefaultApi) regional_clients = {} service_regions = self.get_available_service_regions( service, self._audited_regions @@ -240,7 +247,7 @@ class StackitProvider(Provider): self._service_account_key_path, self._service_account_key, ) - client = IaasDefaultApi(config) + client = api_class(config) client.region = region # Attach region attribute regional_clients[region] = client diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json index d9c8cdb9be..5841d4e32d 100644 --- a/prowler/providers/stackit/stackit_regions_by_service.json +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -5,6 +5,12 @@ "eu01", "eu02" ] + }, + "objectstorage": { + "regions": [ + "eu01", + "eu02" + ] } } } diff --git a/pyproject.toml b/pyproject.toml index f029f3d6f7..28a4b531cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ dependencies = [ "slack-sdk==3.39.0", "stackit-core==0.2.0", "stackit-iaas==1.4.0", + "stackit-objectstorage==1.4.0", "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py new file mode 100644 index 0000000000..2d1e6b7875 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py @@ -0,0 +1,150 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_access_key_expiration: + def test_no_access_keys(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 0 + + def test_access_key_with_expiration(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-123", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has an expiration date set" in result[0].status_extended + assert result[0].resource_id == "key-123" + assert result[0].resource_name == "my-key" + assert result[0].location == "eu01" + + def test_access_key_no_expiration_none(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-456", + display_name="never-expiring-key", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended + assert result[0].resource_id == "key-456" + + def test_access_key_no_expiration_sentinel(self): + """Year-0001 date is the SDK sentinel for 'never expires'.""" + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-789", + display_name="sentinel-key", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py new file mode 100644 index 0000000000..a802dac78f --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_object_lock_enabled: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 0 + + def test_bucket_object_lock_enabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].resource_name == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_object_lock_disabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py new file mode 100644 index 0000000000..59fdf73370 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_retention_policy: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 0 + + def test_bucket_with_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=30, + retention_mode="COMPLIANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 day(s)" in result[0].status_extended + assert "COMPLIANCE" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_without_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + retention_days=None, + retention_mode=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a default retention policy" in result[0].status_extended + ) + assert result[0].resource_id == "my-bucket" + + def test_bucket_retention_zero_days(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=0, + retention_mode="GOVERNANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py new file mode 100644 index 0000000000..59f6559a54 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py @@ -0,0 +1,645 @@ +from types import SimpleNamespace +from unittest import mock + +import pytest + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, + ObjectStorageService, +) +from tests.providers.stackit.stackit_fixtures import STACKIT_PROJECT_ID + + +class TestObjectStorageService: + def test_list_buckets_keeps_bucket_when_retention_not_configured(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found_error = Exception("not found") + not_found_error.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = not_found_error + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "my-bucket" + assert service.buckets[0].object_lock_enabled is True + assert service.buckets[0].retention_days is None + assert service.buckets[0].retention_mode is None + + def test_list_buckets_propagates_unexpected_retention_api_errors(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + api_error = Exception("service unavailable") + api_error.status = 503 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = api_error + + with pytest.raises(Exception, match="service unavailable"): + service._list_buckets(client, "eu01") + + assert service.buckets == [] + service.provider.handle_api_error.assert_called_once_with(api_error) + + def test_init_creates_service_with_no_regions(self): + provider = mock.MagicMock() + provider.identity.project_id = STACKIT_PROJECT_ID + provider.generate_regional_clients.return_value = {} + + service = ObjectStorageService(provider) + + assert service.project_id == STACKIT_PROJECT_ID + assert service.buckets == [] + assert service.access_keys == [] + provider.generate_regional_clients.assert_called_once_with("objectstorage") + + def test_fetch_all_regions_skips_404_region(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + not_found = Exception("not found") + not_found.status = 404 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=not_found): + service._fetch_all_regions() + + assert service.buckets == [] + + def test_fetch_all_regions_reraises_non_404_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + server_error = Exception("internal server error") + server_error.status = 500 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=server_error): + with pytest.raises(Exception, match="internal server error"): + service._fetch_all_regions() + + def test_list_buckets_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = { + "buckets": [ + SimpleNamespace(name="dict-response-bucket", object_lock_enabled=True) + ] + } + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-response-bucket" + + def test_list_buckets_with_dict_bucket_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[{"name": "dict-bucket", "objectLockEnabled": True}] + ) + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-bucket" + assert service.buckets[0].object_lock_enabled is True + + def test_list_buckets_skips_unknown_bucket_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[42]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_get_default_retention_with_dict_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + + client = mock.MagicMock() + client.get_default_retention.return_value = {"days": 14, "mode": "GOVERNANCE"} + + days, mode = service._get_default_retention(client, "eu01", "my-bucket") + + assert days == 14 + assert mode == "GOVERNANCE" + + def test_list_access_keys_with_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001", display_name="main-group")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ) + + service._list_access_keys(client, "eu01") + + client.list_credentials_groups.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01" + ) + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-001" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-001" + assert service.access_keys[0].display_name == "my-key" + assert service.access_keys[0].region == "eu01" + assert service.access_keys[0].expires == "2027-01-01T00:00:00+00:00" + assert service.access_keys[0].credentials_group_id == "cg-001" + assert service.access_keys[0].credentials_group_name == "main-group" + + def test_list_access_keys_with_credentials_group_id_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace( + credentials_group_id="cg-sdk", + display_name="sdk-group", + ) + ] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-sdk" + ) + + def test_list_access_keys_collects_keys_from_multiple_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace(id="cg-001", display_name="group-one"), + SimpleNamespace(id="cg-002", display_name="group-two"), + ] + ) + client.list_access_keys.side_effect = [ + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="key-one", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ), + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-002", + display_name="key-two", + expires=None, + ) + ] + ), + ] + + service._list_access_keys(client, "eu01") + + assert client.list_access_keys.call_args_list == [ + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-001", + ), + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-002", + ), + ] + assert [key.key_id for key in service.access_keys] == ["key-001", "key-002"] + assert service.access_keys[1].expires is None + assert service.access_keys[1].has_expiration() is False + assert [key.credentials_group_id for key in service.access_keys] == [ + "cg-001", + "cg-002", + ] + + def test_list_access_keys_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = { + "credentialsGroups": [{"id": "cg-dict", "displayName": "dict-group"}] + } + client.list_access_keys.return_value = { + "accessKeys": [ + {"keyId": "key-dict", "displayName": "dict-key", "expires": None} + ] + } + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-dict" + assert service.access_keys[0].display_name == "dict-key" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + assert service.access_keys[0].credentials_group_id == "cg-dict" + assert service.access_keys[0].credentials_group_name == "dict-group" + + def test_list_access_keys_with_raw_json_response_and_null_expires(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + + def json(self): + return { + "accessKeys": [ + { + "keyId": "key-raw", + "displayName": "raw-key", + "expires": None, + } + ] + } + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-raw")] + ) + ) + self.list_access_keys = mock.MagicMock() + self.raw_call = None + + def list_access_keys_without_preload_content(self, **kwargs): + self.raw_call = kwargs + return RawResponse() + + client = FakeClient() + + service._list_access_keys(client, "eu01") + + assert client.raw_call == { + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + "credentials_group": "cg-raw", + } + client.list_access_keys.assert_not_called() + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-raw" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + + def test_list_access_keys_with_raw_data_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + data = b'{"accessKeys":[{"keyId":"key-data","displayName":"data-key"}]}' + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-data")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + service._list_access_keys(FakeClient(), "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-data" + assert service.access_keys[0].display_name == "data-key" + + def test_list_access_keys_raw_response_propagates_non_success_status(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 503 + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-error")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + with pytest.raises(Exception, match="status 503") as error: + service._list_access_keys(FakeClient(), "eu01") + + assert error.value.status == 503 + service.provider.handle_api_error.assert_called_once_with(error.value) + + def test_list_access_keys_with_dict_key_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[{"id": "cg-456", "displayName": "group-456"}] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + { + "keyId": "key-456", + "displayName": "my-dict-key", + "expires": "2028-06-01T00:00:00+00:00", + } + ] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-456" + assert service.access_keys[0].display_name == "my-dict-key" + assert service.access_keys[0].credentials_group_id == "cg-456" + + def test_list_access_keys_skips_unknown_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[42]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_keys(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-empty")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + client.list_access_keys.assert_not_called() + + def test_list_access_keys_skips_malformed_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + 42, + {}, + SimpleNamespace(id="cg-valid", display_name="valid-group"), + ] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[SimpleNamespace(key_id="key-valid")] + ) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-valid" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-valid" + + def test_fetch_all_regions_calls_both_list_methods(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + service.regional_clients = {"eu01": mock.MagicMock()} + + with ( + mock.patch.object(service, "_list_buckets") as mock_buckets, + mock.patch.object(service, "_list_access_keys") as mock_keys, + ): + service._fetch_all_regions() + + mock_buckets.assert_called_once() + mock_keys.assert_called_once() + + def test_list_buckets_handles_bucket_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + class BrokenBucket: + @property + def name(self): + raise RuntimeError("broken bucket attribute") + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[BrokenBucket()]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_list_access_keys_handles_key_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class BrokenKey: + @property + def key_id(self): + raise RuntimeError("broken key attribute") + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[BrokenKey()] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + +class TestAccessKeyModel: + def test_has_expiration_with_invalid_date_string(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="not-a-valid-date", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.has_expiration() is False + + def test_expires_within_days_when_no_expiration(self): + key = AccessKey( + key_id="k", + display_name="k", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires is None + assert key.has_expiration() is False + assert key.expires_within_days(90) is False + + def test_expires_within_days_when_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-15T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_when_not_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2030-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(30) is False + + def test_expires_within_days_with_naive_datetime(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-10T00:00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_with_sentinel_key(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is False diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py index 8bcbe17f84..13bc7f4698 100644 --- a/tests/providers/stackit/stackit_provider_test.py +++ b/tests/providers/stackit/stackit_provider_test.py @@ -411,3 +411,68 @@ class Test_StackitProvider_Handle_API_Error: with pytest.raises(RuntimeError) as excinfo: StackitProvider.handle_api_error(original) assert excinfo.value is original + + +class TestGenerateRegionalClients: + """Tests for StackitProvider.generate_regional_clients.""" + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._audited_regions = None + return provider + + def _fake_classes(self): + class FakeConfig: + pass + + class FakeIaasClient: + def __init__(self, config): + pass + + class FakeObjStorageClient: + def __init__(self, config): + pass + + return FakeConfig, FakeIaasClient, FakeObjStorageClient + + def test_objectstorage_service_uses_objectstorage_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("objectstorage") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeObjStorageClient) + + def test_iaas_service_uses_iaas_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("iaas") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeIaasClient) diff --git a/uv.lock b/uv.lock index e42e3ba8af..3a66d2826b 100644 --- a/uv.lock +++ b/uv.lock @@ -3325,6 +3325,7 @@ dependencies = [ { name = "slack-sdk" }, { name = "stackit-core" }, { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, @@ -3433,6 +3434,7 @@ requires-dist = [ { name = "slack-sdk", specifier = "==3.39.0" }, { name = "stackit-core", specifier = "==0.2.0" }, { name = "stackit-iaas", specifier = "==1.4.0" }, + { name = "stackit-objectstorage", specifier = "==1.4.0" }, { name = "stackit-resourcemanager", specifier = "==0.8.0" }, { name = "tabulate", specifier = "==0.9.0" }, { name = "tzlocal", specifier = "==5.3.1" }, @@ -4298,6 +4300,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, ] +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + [[package]] name = "stackit-resourcemanager" version = "0.8.0" From e085e14247d66c789dbb1109fe6fc0e753be9aaf Mon Sep 17 00:00:00 2001 From: sahil-sols <168274064+sahil-sols@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:19:06 +0530 Subject: [PATCH 044/129] fix(aws): order-independent CloudWatch metric filter pattern checks (#11345) Co-authored-by: Sahil Pugalia Co-authored-by: Cursor Co-authored-by: Lydia Vilchez --- prowler/CHANGELOG.md | 1 + ...etwork_acls_alarm_configured.metadata.json | 3 +- ...hanges_to_network_acls_alarm_configured.py | 12 +- ...es_to_network_gateways_alarm_configured.py | 12 +- ...oute_tables_alarm_configured.metadata.json | 2 +- ...o_network_route_tables_alarm_configured.py | 14 +- ...dwatch_changes_to_vpcs_alarm_configured.py | 17 +- ...ws_config_configuration_changes_enabled.py | 11 +- ...loudtrail_configuration_changes_enabled.py | 11 +- ...g_metric_filter_authentication_failures.py | 6 +- ...metric_filter_aws_organizations_changes.py | 28 ++- ...isable_or_scheduled_deletion_of_kms_cmk.py | 6 +- ...for_s3_bucket_policy_changes.metadata.json | 3 +- ...ric_filter_for_s3_bucket_policy_changes.py | 16 +- ...metric_filter_policy_changes.metadata.json | 1 - ...dwatch_log_metric_filter_policy_changes.py | 22 ++- ...og_metric_filter_security_group_changes.py | 12 +- ...c_filter_sign_in_without_mfa.metadata.json | 1 - ...h_log_metric_filter_sign_in_without_mfa.py | 6 +- .../services/cloudwatch/lib/metric_filters.py | 39 ++++ ...s_to_network_acls_alarm_configured_test.py | 182 ++++++++++++++++++ ..._network_gateways_alarm_configured_test.py | 92 +++++++++ ...work_route_tables_alarm_configured_test.py | 182 ++++++++++++++++++ ...h_changes_to_vpcs_alarm_configured_test.py | 182 ++++++++++++++++++ ...nfig_configuration_changes_enabled_test.py | 94 +++++++++ ...rail_configuration_changes_enabled_test.py | 94 +++++++++ ...ric_filter_authentication_failures_test.py | 92 +++++++++ ...c_filter_aws_organizations_changes_test.py | 92 +++++++++ ...e_or_scheduled_deletion_of_kms_cmk_test.py | 94 +++++++++ ...ilter_for_s3_bucket_policy_changes_test.py | 92 +++++++++ ...h_log_metric_filter_policy_changes_test.py | 92 +++++++++ ...tric_filter_security_group_changes_test.py | 92 +++++++++ ..._metric_filter_sign_in_without_mfa_test.py | 92 +++++++++ .../cloudwatch/cloudwatch_service_test.py | 14 ++ 34 files changed, 1689 insertions(+), 20 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 619401bf32..cd6db78f37 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511) - M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) - GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) --- diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json index 20ed5f0adb..9070c7b0c7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json @@ -17,9 +17,8 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11", "https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-", - "https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", "https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py index 20d68a0121..68f45a8d27 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateNetworkAcl", + "CreateNetworkAclEntry", + "DeleteNetworkAcl", + "DeleteNetworkAclEntry", + "ReplaceNetworkAclEntry", + "ReplaceNetworkAclAssociation", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py index 5f6eda3973..f7bf8e1d22 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateCustomerGateway", + "DeleteCustomerGateway", + "AttachInternetGateway", + "CreateInternetGateway", + "DeleteInternetGateway", + "DetachInternetGateway", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json index 82490a63da..89949cfbd6 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json @@ -37,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Logging and Monitoring" } diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py index f8fcc8eacb..460765cb2f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?" + pattern = build_metric_filter_pattern( + event_source="ec2.amazonaws.com", + event_names=[ + "CreateRoute", + "CreateRouteTable", + "ReplaceRoute", + "ReplaceRouteTableAssociation", + "DeleteRouteTable", + "DeleteRoute", + "DisassociateRouteTable", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py index d7606647c4..be4fb0859d 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateVpc", + "DeleteVpc", + "ModifyVpcAttribute", + "AcceptVpcPeeringConnection", + "CreateVpcPeeringConnection", + "DeleteVpcPeeringConnection", + "RejectVpcPeeringConnection", + "AttachClassicLinkVpc", + "DetachClassicLinkVpc", + "DisableVpcClassicLink", + "EnableVpcClassicLink", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py index 11bf08d99d..49bf9a03a3 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change Check ): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?" + pattern = build_metric_filter_pattern( + event_source="config.amazonaws.com", + event_names=[ + "StopConfigurationRecorder", + "DeleteDeliveryChannel", + "PutDeliveryChannel", + "PutConfigurationRecorder", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py index eb272ecfb1..e9567315f4 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change Check ): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateTrail", + "UpdateTrail", + "DeleteTrail", + "StartLogging", + "StopLogging", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py index c217cbbaf6..1b2e5173bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", "=", "Failed authentication")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py index af7ec82119..9976a885bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?" + pattern = build_metric_filter_pattern( + event_source="organizations.amazonaws.com", + event_names=[ + "AcceptHandshake", + "AttachPolicy", + "CancelHandshake", + "CreateAccount", + "CreateOrganization", + "CreateOrganizationalUnit", + "CreatePolicy", + "DeclineHandshake", + "DeleteOrganization", + "DeleteOrganizationalUnit", + "DeletePolicy", + "EnableAllFeatures", + "EnablePolicyType", + "InviteAccountToOrganization", + "LeaveOrganization", + "DetachPolicy", + "DisablePolicyType", + "MoveAccount", + "RemoveAccountFromOrganization", + "UpdateOrganizationalUnit", + "UpdatePolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py index 4cfed985f7..20d1d62a5a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?" + pattern = build_metric_filter_pattern( + event_source="kms.amazonaws.com", + event_names=["DisableKey", "ScheduleKeyDeletion"], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json index 2d77c42704..6292de6071 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json @@ -17,8 +17,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes", - "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12" + "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes" ], "Remediation": { "Code": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py index 45d09b3528..d5fe1ef994 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,20 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?" + pattern = build_metric_filter_pattern( + event_source="s3.amazonaws.com", + event_names=[ + "PutBucketAcl", + "PutBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketReplication", + "DeleteBucketPolicy", + "DeleteBucketCors", + "DeleteBucketLifecycle", + "DeleteBucketReplication", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json index 75cba1ee62..b1c36be2d8 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json @@ -17,7 +17,6 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py index f5efd04dde..4347a9b3aa 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?" + pattern = build_metric_filter_pattern( + event_names=[ + "DeleteGroupPolicy", + "DeleteRolePolicy", + "DeleteUserPolicy", + "PutGroupPolicy", + "PutRolePolicy", + "PutUserPolicy", + "CreatePolicy", + "DeletePolicy", + "CreatePolicyVersion", + "DeletePolicyVersion", + "AttachRolePolicy", + "DetachRolePolicy", + "AttachUserPolicy", + "DetachUserPolicy", + "AttachGroupPolicy", + "DetachGroupPolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py index a7972e420c..3557632904 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?AuthorizeSecurityGroupIngress.+\$\.eventName\s*=\s*.?AuthorizeSecurityGroupEgress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupIngress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupEgress.+\$\.eventName\s*=\s*.?CreateSecurityGroup.+\$\.eventName\s*=\s*.?DeleteSecurityGroup.?" + pattern = build_metric_filter_pattern( + event_names=[ + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress", + "RevokeSecurityGroupIngress", + "RevokeSecurityGroupEgress", + "CreateSecurityGroup", + "DeleteSecurityGroup", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json index 20058c1ed8..76ffd832ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json @@ -21,7 +21,6 @@ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/console-sign-in-without-mfa.html", "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v3.0.0_L1.audit:1957056ee174cc38502d5f5f1864333b", - "https://www.clouddefense.ai/compliance-rules/gdpr/data-protection/log-metric-filter-console-login-mfa", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-no-mfa", "https://support.icompaas.com/support/solutions/articles/62000083605-ensure-a-log-metric-filter-and-alarm-exist-for-management-console-sign-in-without-mfa" ], diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py index 8437600646..07475a6185 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.additionalEventData\.MFAUsed\s*!=\s*.?Yes.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index 84d70b4083..e5d104b840 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -3,6 +3,45 @@ import re from prowler.lib.check.models import Check_Report_AWS +def build_metric_filter_pattern( + *, + event_names: list[str] | None = None, + event_source: str | None = None, + extra_clauses: list[tuple[str, str, str]] | None = None, +) -> str: + """Build a regex pattern to match a CloudWatch Logs filterPattern string. + + All clauses must be present for the pattern to match, regardless of the + order in which AWS stores them. Event names are matched exactly, so a + short name like ``CreateRoute`` will not be satisfied by a longer one + like ``CreateRouteTable``. + + Pass the result directly to ``check_cloudwatch_log_metric_filter``. + + Args: + event_names: AWS API action names to require (``$.eventName``). + event_source: optional service principal to require (``$.eventSource``), + e.g. ``"ec2.amazonaws.com"``. + extra_clauses: additional conditions as ``(field, operator, value)`` + tuples, where ``operator`` is ``"="`` or ``"!="``. Example: + ``("additionalEventData.MFAUsed", "!=", "Yes")``. + + Returns: + A regex string for use with ``re.search(..., flags=re.DOTALL)``. + """ + parts: list[str] = [] + if event_source is not None: + parts.append(rf"(?=.*\$\.eventSource\s*=\s*.?{re.escape(event_source)})") + for name in event_names or []: + parts.append(rf"(?=.*\$\.eventName\s*=\s*.?{re.escape(name)}\b)") + for field, operator, value in extra_clauses or []: + if operator not in ("=", "!="): + raise ValueError(f"unsupported operator {operator!r}; expected '=' or '!='") + op = r"\s*!=\s*" if operator == "!=" else r"\s*=\s*" + parts.append(rf"(?=.*\$\.{re.escape(field)}{op}.?{re.escape(value)})") + return "".join(parts) + + def check_cloudwatch_log_metric_filter( metric_filter_pattern: str, trails: list, diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py index 66c2099843..928ff2b47c 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py @@ -674,3 +674,185 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ReplaceNetworkAclAssociation) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = CreateNetworkAcl) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py index afe0f7d3ce..50c8663437 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py @@ -616,3 +616,95 @@ class Test_cloudwatch_changes_to_network_gateways_alarm_configured: ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [{}] + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = CreateCustomerGateway) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured import ( + cloudwatch_changes_to_network_gateways_alarm_configured, + ) + + check = cloudwatch_changes_to_network_gateways_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py index 7ec6e32c56..929793eb34 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_network_route_tables_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = DisassociateRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DeleteRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = ReplaceRoute) || ($.eventName = CreateRouteTable) || ($.eventName = CreateRoute) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DisassociateRouteTable) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py index 31f27030ff..57d33cb3fe 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_vpcs_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = EnableVpcClassicLink) || ($.eventName = DisableVpcClassicLink) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = ModifyVpcAttribute) || ($.eventName = DeleteVpc) || ($.eventName = CreateVpc) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py index 30982553b2..62ca14d4ba 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py @@ -665,3 +665,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = config.amazonaws.com) && (($.eventName = PutConfigurationRecorder) || ($.eventName = PutDeliveryChannel) || ($.eventName = DeleteDeliveryChannel) || ($.eventName = StopConfigurationRecorder)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py index fcb98fda3e..3b555bf05b 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_c == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = StopLogging) || ($.eventName = StartLogging) || ($.eventName = DeleteTrail) || ($.eventName = UpdateTrail) || ($.eventName = CreateTrail) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py index 66b9d8116b..201be76f6d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_authentication_failures: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.errorMessage = Failed authentication) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures import ( + cloudwatch_log_metric_filter_authentication_failures, + ) + + check = cloudwatch_log_metric_filter_authentication_failures() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py index 3aaf475cbe..534c50c906 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_aws_organizations_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = organizations.amazonaws.com) && ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = MoveAccount) || ($.eventName = DisablePolicyType) || ($.eventName = DetachPolicy) || ($.eventName = LeaveOrganization) || ($.eventName = InviteAccountToOrganization) || ($.eventName = EnablePolicyType) || ($.eventName = EnableAllFeatures) || ($.eventName = DeletePolicy) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeleteOrganization) || ($.eventName = DeclineHandshake) || ($.eventName = CreatePolicy) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreateOrganization) || ($.eventName = CreateAccount) || ($.eventName = CancelHandshake) || ($.eventName = AttachPolicy) || ($.eventName = AcceptHandshake) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes import ( + cloudwatch_log_metric_filter_aws_organizations_changes, + ) + + check = cloudwatch_log_metric_filter_aws_organizations_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py index 7eb1cae331..f13263cf9f 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = kms.amazonaws.com) && (($.eventName = ScheduleKeyDeletion) || ($.eventName = DisableKey)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk import ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk, + ) + + check = ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py index 7d2d631f11..aa88f5164d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_for_s3_bucket_policy_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = s3.amazonaws.com) && (($.eventName = DeleteBucketReplication) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketPolicy) || ($.eventName = PutBucketReplication) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketAcl)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes import ( + cloudwatch_log_metric_filter_for_s3_bucket_policy_changes, + ) + + check = cloudwatch_log_metric_filter_for_s3_bucket_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py index 06e36688d6..fd3e86d313 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachGroupPolicy) || ($.eventName = AttachGroupPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachUserPolicy) || ($.eventName = DetachRolePolicy) || ($.eventName = AttachRolePolicy) || ($.eventName = DeletePolicyVersion) || ($.eventName = CreatePolicyVersion) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicy) || ($.eventName = PutUserPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutGroupPolicy) || ($.eventName = DeleteUserPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteGroupPolicy) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes import ( + cloudwatch_log_metric_filter_policy_changes, + ) + + check = cloudwatch_log_metric_filter_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py index ede85e8e6e..35dee7b516 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py @@ -599,3 +599,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DeleteSecurityGroup) || ($.eventName = CreateSecurityGroup) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = AuthorizeSecurityGroupIngress) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes import ( + cloudwatch_log_metric_filter_security_group_changes, + ) + + check = cloudwatch_log_metric_filter_security_group_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py index df67472cdd..6944860d75 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_sign_in_without_mfa: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.additionalEventData.MFAUsed != Yes) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa import ( + cloudwatch_log_metric_filter_sign_in_without_mfa, + ) + + check = cloudwatch_log_metric_filter_sign_in_without_mfa() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 8ae7ed980f..33d4bde7d7 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,3 +1,4 @@ +import pytest from boto3 import client from moto import mock_aws @@ -5,6 +6,9 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, Logs, ) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1, @@ -216,3 +220,13 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].kms_id == "test_kms_id" assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + + +class Test_build_metric_filter_pattern: + @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) + def test_rejects_unsupported_operator(self, bad_operator): + with pytest.raises(ValueError, match="unsupported operator"): + build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], + ) From 75f95559d629f371b5618c3f4c243d8b54a975a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 19:04:40 +0200 Subject: [PATCH 045/129] fix(api): warm compliance caches when starting the worker (#11530) --- api/CHANGELOG.md | 1 + api/src/backend/api/compliance.py | 63 +++++++++++++ api/src/backend/api/exceptions.py | 26 ++++++ api/src/backend/api/tests/test_compliance.py | 93 +++++++++++++++++++ api/src/backend/api/tests/test_views.py | 33 +++++++ api/src/backend/api/v1/views.py | 10 ++ api/src/backend/config/guniconf.py | 25 +++++ ui/CHANGELOG.md | 1 + ui/actions/compliances/compliances.ts | 7 ++ .../compliance/[compliancetitle]/page.tsx | 11 +++ .../compliance/compliance-warming.tsx | 49 ++++++++++ ui/components/compliance/index.ts | 1 + 12 files changed, 320 insertions(+) create mode 100644 ui/components/compliance/compliance-warming.tsx diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2033dcc420..6759b28741 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler API** are documented in this file. - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) +- Compliance catalog now warms in a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) ### 🔐 Security diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 678aff8d57..202825185b 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,3 +1,5 @@ +import logging +import threading from collections.abc import Iterable, Mapping from api.models import Provider @@ -6,8 +8,19 @@ from prowler.lib.check.compliance_models import ( ) from prowler.lib.check.models import CheckMetadata +logger = logging.getLogger(__name__) + AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +# Per-process readiness flags for the background compliance warm-up. +# `STARTED` is set as soon as warming begins (only happens under Gunicorn via +# the post_fork hook); `WARMED` is set when it finishes. The attributes +# endpoint checks both: it returns 503 only while warming is in progress. +# Under `runserver` warming never runs, so `STARTED` stays clear and the +# endpoint keeps lazy-loading as before. +COMPLIANCE_WARMING_STARTED = threading.Event() +COMPLIANCE_WARMED = threading.Event() + class LazyComplianceTemplate(Mapping): """Lazy-load compliance templates per provider on first access.""" @@ -174,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: PROWLER_CHECKS._cache[provider_type] = checks +def warm_compliance_caches( + provider_types: Iterable[str] | None = None, +) -> list[str]: + """ + Eagerly populate the per-process compliance caches at server startup. + + Moves the cold-cache catalog load off the request thread so the first + request does not trip the Gunicorn worker timeout. Reads only on-disk + metadata (no database access). Each provider is warmed in isolation; + failures are logged and fall back to lazy loading. + + Args: + provider_types (Iterable[str] | None): Subset to warm. Defaults to all. + + Returns: + list[str]: Provider types that could not be warmed. + """ + if provider_types is None: + provider_types = Provider.ProviderChoices.values + provider_types = list(provider_types) + + COMPLIANCE_WARMING_STARTED.set() + logger.info("Compliance cache warm-up started for providers: %s", provider_types) + + failed = [] + for provider_type in provider_types: + try: + get_compliance_frameworks(provider_type) + _ensure_provider_loaded(provider_type) + # Prowler check loading may sys.exit (SystemExit, not Exception). + except (Exception, SystemExit): + logger.warning( + "Failed to warm compliance caches for provider '%s'; " + "loading lazily on first request", + provider_type, + exc_info=True, + ) + failed.append(provider_type) + + # Mark as warmed even when some providers failed: a failed provider falls + # back to a single-provider lazy load, which stays under the worker timeout. + COMPLIANCE_WARMED.set() + logger.info( + "Compliance cache warm-up finished (providers warmed: %d, failed: %s)", + len(provider_types) - len(failed), + failed, + ) + return failed + + def load_prowler_checks( prowler_compliance, provider_types: Iterable[str] | None = None ): diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 78f8c64c7d..4f6f26c2ea 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException): ) +class ComplianceWarmingError(APIException): + """Compliance catalog is still warming (503 Service Unavailable). + + Returned by the compliance attributes endpoint while the per-process + catalog warm-up is in progress, so the request thread never triggers the + slow cold load that would trip the Gunicorn worker timeout. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = ( + "Compliance data is still loading. Please try again in a few seconds." + ) + default_code = "compliance_warming" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + class UpstreamInternalError(APIException): """Unexpected error communicating with provider (500 Internal Server Error). diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 508e5abaca..99a31ea12c 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -10,6 +10,7 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, + warm_compliance_caches, ) from api.models import Provider from prowler.lib.check.compliance_models import ( @@ -267,11 +268,17 @@ def reset_compliance_cache(): """Reset the module-level cache so each test starts cold.""" previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS) compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + # The warming flags are module-global; clear them so they do not leak + # between tests that call warm_compliance_caches. + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() try: yield finally: compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous) + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() class TestGetComplianceFrameworks: @@ -321,3 +328,89 @@ class TestGetComplianceFrameworks: f"loadable by get_bulk_compliance_frameworks_universal: " f"{sorted(missing)}" ) + + +class TestWarmComplianceCaches: + def test_warms_all_provider_types_by_default(self, reset_compliance_cache): + provider_types = list(Provider.ProviderChoices.values) + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches() + + warmed = {call.args[0] for call in mock_frameworks.call_args_list} + assert warmed == set(provider_types) + assert mock_frameworks.call_count == len(provider_types) + assert mock_ensure.call_count == len(provider_types) + + def test_warms_only_requested_provider_types(self, reset_compliance_cache): + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_populates_module_cache(self, reset_compliance_cache): + with ( + patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk, + patch("api.compliance._ensure_provider_loaded"), + ): + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert ( + Provider.ProviderChoices.AWS + in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS + ) + + def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache): + """A failing provider (even on SystemExit) is isolated; others warm.""" + providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA] + + def fake_frameworks(provider_type): + if provider_type == Provider.ProviderChoices.OKTA: + raise SystemExit(1) + return [] + + with ( + patch( + "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks + ), + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + failed = warm_compliance_caches(providers) + + assert failed == [Provider.ProviderChoices.OKTA] + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_sets_readiness_flags(self, reset_compliance_cache): + assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert not compliance_module.COMPLIANCE_WARMED.is_set() + + with ( + patch("api.compliance.get_compliance_frameworks"), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert compliance_module.COMPLIANCE_WARMED.is_set() + + def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache): + """A failed provider still leaves the caches flagged as warmed.""" + with ( + patch( + "api.compliance.get_compliance_frameworks", + side_effect=SystemExit(1), + ), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMED.is_set() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index d213e8c855..ab90bdbde0 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9578,6 +9578,39 @@ class TestComplianceOverviewViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_compliance_overview_attributes_503_while_warming( + self, authenticated_client + ): + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.set() + COMPLIANCE_WARMED.clear() + try: + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + finally: + COMPLIANCE_WARMING_STARTED.clear() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.json()["errors"][0]["code"] == "compliance_warming" + + def test_compliance_overview_attributes_serves_when_warming_not_started( + self, authenticated_client + ): + # Dev fallback: under runserver warming never runs, so the guard must + # not refuse — the endpoint lazily loads and serves as before. + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.clear() + COMPLIANCE_WARMED.clear() + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + def test_compliance_overview_task_management_integration( self, authenticated_client, compliance_requirements_overviews_fixture ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 9c91c3201f..45100f1018 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -114,6 +114,8 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( + COMPLIANCE_WARMED, + COMPLIANCE_WARMING_STARTED, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, get_prowler_provider_compliance, @@ -122,6 +124,7 @@ from api.constants import SEVERITY_ORDER from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ( + ComplianceWarmingError, TaskFailedException, UpstreamAccessDeniedError, UpstreamAuthenticationError, @@ -5059,6 +5062,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="attributes") def attributes(self, request): + # While the background warm-up is in progress, refuse immediately + # instead of falling through to the slow cold load on the request + # thread (which would trip the Gunicorn worker timeout). `is_set()` is + # a non-blocking flag read, so this never touches the loader. + if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set(): + raise ComplianceWarmingError() + compliance_id = request.query_params.get("filter[compliance_id]") if not compliance_id: raise ValidationError( diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 536fd97abb..a16c8de9a0 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -1,6 +1,7 @@ import logging import multiprocessing import os +import threading from config.env import env @@ -11,6 +12,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() +from api.compliance import warm_compliance_caches # noqa: E402 from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 @@ -41,3 +43,26 @@ def on_reload(_): def when_ready(_): gunicorn_logger.info("Gunicorn server is ready") + + +def _warm_compliance_caches_in_background(): + """Warm compliance caches off the request path and log the outcome.""" + failed = warm_compliance_caches() + if failed: + gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed) + else: + gunicorn_logger.info("Compliance caches warmed") + + +def post_fork(_server, worker): + """Warm compliance caches after each worker fork. + + Warm compliance caches in a background thread so the worker becomes ready + immediately. A request for a not-yet-warmed provider lazily loads just that + provider, which stays well under the worker timeout. + """ + threading.Thread( + target=_warm_compliance_caches_in_background, + name="warm-compliance-caches", + daemon=True, + ).start() diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc8c0fa708..57d0427dc9 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) +- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index d5f4fd4954..58e7c5b5a0 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -84,6 +84,13 @@ export const getComplianceAttributes = async (complianceId: string) => { headers, }); + // The compliance catalog is still warming after a deploy/restart. Signal + // the page to render the "still loading" state instead of letting this + // become a thrown 5xx (which would be captured as a server error). + if (response.status === 503) { + return { warming: true as const, status: 503 }; + } + return handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 71e7870f8d..445710e48f 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -13,6 +13,7 @@ import { ClientAccordionWrapper, ComplianceDownloadContainer, ComplianceHeader, + ComplianceWarming, RequirementsStatusCard, RequirementsStatusCardSkeleton, // SectionsFailureRateCard, @@ -92,6 +93,16 @@ export default async function ComplianceDetail({ : Promise.resolve(null), ]); + // The compliance catalog is still warming after a deploy/restart. Show the + // "still loading" state with a Try Again instead of rendering an empty page. + if (attributesData?.warming) { + return ( + + + + ); + } + if (selectedScanResponse?.data) { const scan = selectedScanResponse.data; const providerId = scan.relationships?.provider?.data?.id; diff --git a/ui/components/compliance/compliance-warming.tsx b/ui/components/compliance/compliance-warming.tsx new file mode 100644 index 0000000000..19864ae121 --- /dev/null +++ b/ui/components/compliance/compliance-warming.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { Card, CardContent } from "@/components/shadcn/card/card"; + +export const ComplianceWarming = () => { + const router = useRouter(); + + return ( +
+
+ + +
+
+ +
+

+ Compliance data is still loading +

+

+ This can happen for a few seconds right after an update. + Please try again shortly. +

+
+
+ +
+
+
+
+
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index e0bd631200..caea32e264 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,6 +19,7 @@ export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-header/data-compliance"; export * from "./compliance-header/scan-selector"; export * from "./compliance-overview-grid"; +export * from "./compliance-warming"; export * from "./no-scans-available"; export * from "./skeletons/bar-chart-skeleton"; export * from "./skeletons/compliance-accordion-skeleton"; From 989c3b174e9c58a58e6cdb60a7ac93b87aa4c59c Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:31:08 +0200 Subject: [PATCH 046/129] fix(bedrock): per-finding severity for long-term API key check (#11526) --- prowler/CHANGELOG.md | 1 + ...key_no_long_term_credentials.metadata.json | 19 +- ...edrock_api_key_no_long_term_credentials.py | 81 ++- ...k_api_key_no_long_term_credentials_test.py | 593 +++++------------- 4 files changed, 220 insertions(+), 474 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index cd6db78f37..96ccb4bb29 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) - GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) +- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) --- diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json index f9b8ee0df9..c85279e4a4 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "bedrock_api_key_no_long_term_credentials", - "CheckTitle": "Amazon Bedrock API key is expired", + "CheckTitle": "Amazon Bedrock long-term API key has expired", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,23 +14,24 @@ "Severity": "high", "ResourceType": "AwsIamUser", "ResourceGroup": "IAM", - "Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.", - "Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation", + "Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.", + "Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html", - "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials", - "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials" ], "Remediation": { "Code": { "CLI": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", "NativeIaC": "", - "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)", + "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS", "Terraform": "" }, "Recommendation": { - "Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.", "Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials" } }, @@ -40,5 +41,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege." + "Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire." } diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py index 2edffd7ccb..9697ecbff0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py @@ -1,49 +1,62 @@ from datetime import datetime, timezone -from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.check.models import Check, Check_Report_AWS, Severity from prowler.providers.aws.services.iam.iam_client import iam_client +# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring. +NEVER_EXPIRES_THRESHOLD_DAYS = 10000 + class bedrock_api_key_no_long_term_credentials(Check): - """ - Bedrock API keys should be short-lived to reduce the risk of unauthorized access. - This check verifies if there are any long-term Bedrock API keys. - If there are, it checks if they are expired or will be expired. - If they are expired, it will be marked as PASS. - If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire. + """Amazon Bedrock long-term API keys should not be used outside of exploration. + + AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours) + for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates + long-term keys, so every key inspected here is by definition a long-term credential. + + PASS when the long-term key has already expired (it can no longer authenticate). + FAIL (critical) when the key is configured to never expire. + FAIL (high) for any other active long-term key. """ def execute(self): - """ - Execute the Bedrock API key no long-term credentials check. - - Iterate over all the Bedrock API keys and check if they are expired or will be expired. - - Returns: - List[Check_Report_AWS]: A list of report objects with the results of the check. - """ - findings = [] for api_key in iam_client.service_specific_credentials: if api_key.service_name != "bedrock.amazonaws.com": continue - if api_key.expiration_date: - report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) - # Check if the expiration date is in the future - if api_key.expiration_date > datetime.now(timezone.utc): - report.status = "FAIL" - # Get the days until the expiration date - days_until_expiration = ( - api_key.expiration_date - datetime.now(timezone.utc) - ).days - if days_until_expiration > 10000: - self.Severity = "critical" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires." - else: - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days." - else: - report.status = "PASS" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired." - findings.append(report) + if not api_key.expiration_date: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) + now = datetime.now(timezone.utc) + + if api_key.expiration_date <= now: + report.status = "PASS" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} has already expired and can no longer " + f"authenticate." + ) + elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS: + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is configured to never expire. Use " + f"short-term Bedrock API keys (session-scoped, valid up to " + f"12 hours) for non-exploratory workloads instead." + ) + else: + days_until_expiration = (api_key.expiration_date - now).days + report.status = "FAIL" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is active and will expire in " + f"{days_until_expiration} days. Use short-term Bedrock API " + f"keys (session-scoped, valid up to 12 hours) for " + f"non-exploratory workloads instead." + ) + + findings.append(report) return findings diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py index bf4f6381b9..d75d248c7c 100644 --- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py @@ -3,466 +3,197 @@ from unittest import mock from moto import mock_aws +from prowler.lib.check.models import Severity from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +BEDROCK_SERVICE = "bedrock.amazonaws.com" + + +def _make_user(name="test_user"): + from prowler.providers.aws.services.iam.iam_service import User + + return User( + name=name, + arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{name}", + attached_policies=[], + inline_policies=[], + ) + + +def _make_credential( + user, + credential_id="test-credential-id", + expiration_delta_days=None, + service_name=BEDROCK_SERVICE, +): + from prowler.providers.aws.services.iam.iam_service import ServiceSpecificCredential + + expiration_date = ( + datetime.now(timezone.utc) + timedelta(days=expiration_delta_days) + if expiration_delta_days is not None + else None + ) + return ServiceSpecificCredential( + arn=( + f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{user.name}/" + f"credential/{credential_id}" + ), + user=user, + status="Active", + create_date=datetime.now(timezone.utc), + service_user_name=None, + service_credential_alias=None, + expiration_date=expiration_date, + id=credential_id, + service_name=service_name, + region=AWS_REGION_US_EAST_1, + ) + + +def _run_check(credentials): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.service_specific_credentials = credentials + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", + new=iam, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( + bedrock_api_key_no_long_term_credentials, + ) + + check = bedrock_api_key_no_long_term_credentials() + return check.execute() + class Test_bedrock_api_key_no_long_term_credentials: @mock_aws def test_no_bedrock_api_keys(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=IAM(aws_provider), - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert _run_check([]) == [] @mock_aws - def test_bedrock_api_key_with_future_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_short_expiration_key_fails_high(self): + # Per AWS guidance, every active long-term key is a finding regardless of + # how soon it expires. Short remaining lifetime does not downgrade severity. + credential = _make_credential(_make_user(), expiration_delta_days=30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "will expire in" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_critical_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_long_expiration_key_fails_high(self): + credential = _make_credential(_make_user(), expiration_delta_days=365) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with very far future expiration date (>10000 days) - expiration_date = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "never expires" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 - assert check.Severity == "critical" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_expired_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_never_expires_key_fails_critical(self): + # >10000 days approximates AWS's "no expiration" sentinel (~100 years). + credential = _make_credential(_make_user(), expiration_delta_days=15000) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with past expiration date - expiration_date = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "PASS" - assert "has expired" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "configured to never expire" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_without_expiration_date_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_already_expired_key_passes(self): + credential = _make_credential(_make_user(), expiration_delta_days=-30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential without expiration date (should be ignored) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=None, # No expiration date - should be ignored - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has already expired" in result[0].status_extended @mock_aws - def test_non_bedrock_api_key_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential for a different service - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="codecommit.amazonaws.com", # Different service - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + def test_key_without_expiration_date_ignored(self): + credential = _make_credential(_make_user(), expiration_delta_days=None) + assert _run_check([credential]) == [] @mock_aws - def test_multiple_bedrock_api_keys_mixed_scenarios(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, + def test_non_bedrock_service_ignored(self): + credential = _make_credential( + _make_user(), + expiration_delta_days=30, + service_name="codecommit.amazonaws.com", ) + assert _run_check([credential]) == [] - # Create mock users - mock_user1 = User( - name="test_user1", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1", - attached_policies=[], - inline_policies=[], + @mock_aws + def test_mixed_scenarios(self): + user1, user2, user3 = ( + _make_user("u1"), + _make_user("u2"), + _make_user("u3"), ) - - mock_user2 = User( - name="test_user2", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2", - attached_policies=[], - inline_policies=[], - ) - - mock_user3 = User( - name="test_user3", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date1 = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential1 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1/credential/test-credential-id-1", - user=mock_user1, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date1, - id="test-credential-id-1", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with critical expiration date - expiration_date2 = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential2 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2/credential/test-credential-id-2", - user=mock_user2, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date2, - id="test-credential-id-2", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with expired date - expiration_date3 = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential3 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3/credential/test-credential-id-3", - user=mock_user3, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date3, - id="test-credential-id-3", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [ - mock_credential1, - mock_credential2, - mock_credential3, + credentials = [ + _make_credential(user1, "active-key", expiration_delta_days=191), + _make_credential(user2, "never-key", expiration_delta_days=15000), + _make_credential(user3, "expired-key", expiration_delta_days=-30), ] + result = _run_check(credentials) - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, + assert len(result) == 3 + by_id = {r.resource_id: r for r in result} + + assert by_id["active-key"].status == "FAIL" + assert by_id["active-key"].check_metadata.Severity == Severity.high + + assert by_id["never-key"].status == "FAIL" + assert by_id["never-key"].check_metadata.Severity == Severity.critical + + assert by_id["expired-key"].status == "PASS" + + @mock_aws + def test_severity_does_not_leak_never_then_active(self): + """Regression: a never-expires key processed before an active key must + not bleed `critical` severity into the active finding.""" + credentials = [ + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) + ] + result = _run_check(credentials) - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() + by_id = {r.resource_id: r for r in result} + assert by_id["never-key"].check_metadata.Severity == Severity.critical + assert by_id["active-key"].check_metadata.Severity == Severity.high - assert len(result) == 3 + @mock_aws + def test_severity_does_not_leak_active_then_never(self): + """Regression: same as above with the reverse iteration order.""" + credentials = [ + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 + ), + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 + ), + ] + result = _run_check(credentials) - # Check the credential with future expiration date (FAIL) - fail_result1 = next( - r for r in result if r.resource_id == "test-credential-id-1" - ) - assert fail_result1.status == "FAIL" - assert "will expire in" in fail_result1.status_extended - assert "test-credential-id-1" in fail_result1.status_extended - assert "test_user1" in fail_result1.status_extended - - # Check the credential with critical expiration date (FAIL) - fail_result2 = next( - r for r in result if r.resource_id == "test-credential-id-2" - ) - assert fail_result2.status == "FAIL" - assert "never expires" in fail_result2.status_extended - assert "test-credential-id-2" in fail_result2.status_extended - assert "test_user2" in fail_result2.status_extended - - # Check the credential with expired date (PASS) - pass_result = next( - r for r in result if r.resource_id == "test-credential-id-3" - ) - assert pass_result.status == "PASS" - assert "has expired" in pass_result.status_extended - assert "test-credential-id-3" in pass_result.status_extended - assert "test_user3" in pass_result.status_extended + by_id = {r.resource_id: r for r in result} + assert by_id["active-key"].check_metadata.Severity == Severity.high + assert by_id["never-key"].check_metadata.Severity == Severity.critical From 285974b7d4e6b497f2862bd37d3ac7709d1d515f Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Thu, 11 Jun 2026 09:08:25 +0200 Subject: [PATCH 047/129] chore(changelog): v5.30.0 (#11540) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> --- api/CHANGELOG.md | 6 +++--- prowler/CHANGELOG.md | 10 +++++----- ui/CHANGELOG.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 6759b28741..8f3585fd20 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.31.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler v5.30.0) ### 🚀 Added @@ -19,11 +19,11 @@ All notable changes to the **Prowler API** are documented in this file. - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) -- Compliance catalog now warms in a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) ### 🔐 Security -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) --- diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 96ccb4bb29..bb206f5a96 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.30.0] (Prowler UNRELEASED) +## [5.30.0] (Prowler v5.30.0) ### 🚀 Added @@ -32,6 +32,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) - AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + --- ## [5.29.3] (Prowler v5.29.3) @@ -43,10 +47,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) -### 🔐 Security - -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) - --- ## [5.29.1] (Prowler v5.29.1) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 57d0427dc9..2676778fef 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.30.0] (Prowler UNRELEASED) +## [1.30.0] (Prowler v5.30.0) ### 🚀 Added From f1d741214a60df17158c3fdc97804fd1fde64f3a Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:44:17 +0200 Subject: [PATCH 048/129] fix(ui): adapt risk pipeline sankey layout (#11527) --- ui/CHANGELOG.md | 4 + .../graphs/sankey-chart.layout.test.ts | 84 ++++++++++++++++ ui/components/graphs/sankey-chart.layout.ts | 70 +++++++++++++ ui/components/graphs/sankey-chart.test.tsx | 98 +++++++++++++++++++ ui/components/graphs/sankey-chart.tsx | 28 ++++-- 5 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 ui/components/graphs/sankey-chart.layout.test.ts create mode 100644 ui/components/graphs/sankey-chart.layout.ts create mode 100644 ui/components/graphs/sankey-chart.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2676778fef..67a0a59e5d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) - Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +### 🐞 Fixed + +- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527) + --- ## [1.29.3] (Prowler v5.29.3) diff --git a/ui/components/graphs/sankey-chart.layout.test.ts b/ui/components/graphs/sankey-chart.layout.test.ts new file mode 100644 index 0000000000..95fe9b624e --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; + +describe("getSankeyLayoutConfig", () => { + it("keeps default size when provider count is at baseline", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: [ + { name: "AWS" }, + { name: "High" }, + { name: "Medium" }, + { name: "Low" }, + { name: "Azure" }, + { name: "Info" }, + { name: "GCP" }, + ], + links: [{ source: 0 }, { source: 4 }, { source: 6 }], + }); + + expect(config).toEqual({ + height: 460, + nodePadding: 50, + }); + }); + + it("increases height and reduces node padding for denser graphs", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 24 }, (_, index) => ({ + name: `Provider ${index}`, + })), + links: [ + { source: 0 }, + { source: 1 }, + { source: 2 }, + { source: 3 }, + { source: 4 }, + { source: 5 }, + { source: 6 }, + { source: 7 }, + { source: 8 }, + { source: 9 }, + { source: 10 }, + { source: 11 }, + ], + }); + + expect(config).toEqual({ + height: 844, + nodePadding: 38, + }); + }); + + it("clamps padding to minimum when provider count is very large", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 120 }, (_, index) => ({ + name: `Provider ${index}`, + })), + links: Array.from({ length: 100 }, (_, index) => ({ + source: index, + })), + }); + + expect(config.nodePadding).toBe(14); + expect(config.height).toBe(1400); + }); + + it("falls back to node-based provider estimation when no link sources exist", () => { + const config = getSankeyLayoutConfig({ + baseHeight: 460, + nodes: Array.from({ length: 8 }, (_, index) => ({ + name: `Node ${index}`, + })), + links: [], + }); + + expect(config).toEqual({ + height: 460, + nodePadding: 50, + }); + }); +}); diff --git a/ui/components/graphs/sankey-chart.layout.ts b/ui/components/graphs/sankey-chart.layout.ts new file mode 100644 index 0000000000..fd8ba39562 --- /dev/null +++ b/ui/components/graphs/sankey-chart.layout.ts @@ -0,0 +1,70 @@ +export interface SankeyNodeLike { + name: string; +} + +export interface SankeyLinkLike { + source: number; +} + +export interface SankeyLayoutInput { + baseHeight: number; + nodes: SankeyNodeLike[]; + links: SankeyLinkLike[]; +} + +export interface SankeyLayoutConfig { + height: number; + nodePadding: number; +} + +const SANKEY_DEFAULT_NODE_PADDING = 50; +const SANKEY_MIN_NODE_PADDING = 14; +const SANKEY_HEIGHT_GROWTH_PER_PROVIDER = 64; +const SANKEY_BASE_PROVIDER_COUNT = 6; +const SANKEY_MAX_HEIGHT = 1400; + +const getProviderNodeCount = ({ nodes, links }: SankeyLayoutInput): number => { + const uniqueSourceIndexes = new Set(); + + links.forEach((link) => { + uniqueSourceIndexes.add(link.source); + }); + + if (uniqueSourceIndexes.size > 0) { + return uniqueSourceIndexes.size; + } + + return Math.max(0, nodes.length - 5); +}; + +const getNodePaddingForProviderCount = (providerNodeCount: number): number => { + const compactedProviders = Math.max( + 0, + providerNodeCount - SANKEY_BASE_PROVIDER_COUNT, + ); + return Math.max( + SANKEY_MIN_NODE_PADDING, + Math.round(SANKEY_DEFAULT_NODE_PADDING - compactedProviders * 2), + ); +}; + +export const getSankeyLayoutConfig = ( + params: SankeyLayoutInput, +): SankeyLayoutConfig => { + const providerNodeCount = getProviderNodeCount(params); + const extraProviders = Math.max( + 0, + providerNodeCount - SANKEY_BASE_PROVIDER_COUNT, + ); + const dynamicHeight = Math.min( + SANKEY_MAX_HEIGHT, + Math.round( + params.baseHeight + extraProviders * SANKEY_HEIGHT_GROWTH_PER_PROVIDER, + ), + ); + + return { + height: dynamicHeight, + nodePadding: getNodePaddingForProviderCount(providerNodeCount), + }; +}; diff --git a/ui/components/graphs/sankey-chart.test.tsx b/ui/components/graphs/sankey-chart.test.tsx new file mode 100644 index 0000000000..48c9e01a04 --- /dev/null +++ b/ui/components/graphs/sankey-chart.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CustomNode, SankeyChart } from "./sankey-chart"; +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; + +const mockPush = vi.fn(); + +vi.mock("@/lib", () => ({ + applyFailNonMutedFilters: (filters: unknown) => filters, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams(), +})); + +describe("SankeyChart", () => { + it("uses layout-configured height for the empty-state container", () => { + const data = { + nodes: Array.from({ length: 14 }, (_, index) => ({ + name: `Node ${index}`, + })), + links: Array.from({ length: 12 }, (_, index) => ({ + source: index, + target: 13, + value: 0, + })), + }; + + const baseHeight = 460; + const layoutConfig = getSankeyLayoutConfig({ + baseHeight, + nodes: data.nodes, + links: data.links, + }); + + const { container } = render( + , + ); + + expect( + screen.getByText("No failed findings to display"), + ).toBeInTheDocument(); + expect(container.firstElementChild).toHaveStyle({ + height: `${layoutConfig.height}px`, + }); + }); + + it("renders risk and severity node labels with middle-aligned text", () => { + const x = 10; + const y = 20; + const width = 70; + const height = 80; + const nodeCenterY = y + height / 2; + + render( + + + , + ); + + const textElements = screen.getAllByText(/High|9/); + const nameLabel = textElements.find( + (element) => element.textContent === "High", + ); + const valueLabel = textElements.find( + (element) => element.textContent === "9", + ); + + expect(nameLabel).toBeDefined(); + expect(valueLabel).toBeDefined(); + + if (!nameLabel || !valueLabel) { + throw new Error( + "Expected both node name and value labels to be rendered.", + ); + } + + expect(nameLabel).toHaveAttribute("dominant-baseline", "middle"); + expect(valueLabel).toHaveAttribute("dominant-baseline", "middle"); + + const nameY = Number.parseFloat(nameLabel.getAttribute("y") || "0"); + const valueY = Number.parseFloat(valueLabel.getAttribute("y") || "0"); + + expect(nameY).toBeLessThan(nodeCenterY); + expect(valueY).toBeGreaterThan(nodeCenterY); + expect((nameY + valueY) / 2).toBeCloseTo(nodeCenterY); + }); +}); diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index 0a41d0e4e7..f65647d73e 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -11,6 +11,7 @@ import { initializeChartColors } from "@/lib/charts/colors"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; +import { getSankeyLayoutConfig } from "./sankey-chart.layout"; import { ChartTooltip } from "./shared/chart-tooltip"; // Reverse mapping from display name to provider type for URL filters @@ -73,6 +74,7 @@ interface NodeTooltipState { const TOOLTIP_OFFSET_PX = 10; const MIN_LINK_WIDTH = 4; +const NODE_LABEL_LINE_SPACING = 13; interface TooltipPayload { payload: { @@ -88,7 +90,7 @@ interface TooltipProps { payload?: TooltipPayload[]; } -interface CustomNodeProps { +export interface CustomNodeProps { x: number; y: number; width: number; @@ -148,7 +150,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { return null; }; -const CustomNode = ({ +export const CustomNode = ({ x, y, width, @@ -215,6 +217,10 @@ const CustomNode = ({ const iconSize = 24; const iconGap = 8; + const nodeCenterY = y + height / 2; + const nodeNameY = nodeCenterY - NODE_LABEL_LINE_SPACING / 2; + const nodeValueY = nodeCenterY + NODE_LABEL_LINE_SPACING / 2; + // Calculate text position accounting for icon const textOffsetX = isOut ? x - 6 : x + width + 6; const iconOffsetX = isOut @@ -260,8 +266,9 @@ const CustomNode = ({ : textOffsetX + iconSize + iconGap * 2 : textOffsetX } - y={y + height / 2} + y={nodeNameY} fontSize="14" + dominantBaseline="middle" fill="var(--color-text-neutral-primary)" > {nodeName} @@ -275,8 +282,9 @@ const CustomNode = ({ : textOffsetX + iconSize + iconGap * 2 : textOffsetX } - y={y + height / 2 + 13} + y={nodeValueY} fontSize="12" + dominantBaseline="middle" fill="var(--color-text-neutral-secondary)" > {payload.value} @@ -531,11 +539,17 @@ export function SankeyChart({ // Check if there's actual data to display (links with values > 0) const hasData = data.links.some((link) => link.value > 0); + const layoutConfig = getSankeyLayoutConfig({ + baseHeight: height, + nodes: data.nodes, + links: data.links, + }); + if (!hasData) { return (
@@ -549,12 +563,12 @@ export function SankeyChart({ return (
- + From c4378d5992137773fe7766fa12c97b82403ed396 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 11 Jun 2026 15:28:25 +0200 Subject: [PATCH 049/129] chore(release): Bump versions to v5.31.0 (#11548) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- .env | 2 +- api/pyproject.toml | 2 +- api/src/backend/api/specs/v1.yaml | 2 +- api/uv.lock | 2 +- docs/getting-started/installation/prowler-app.mdx | 4 ++-- prowler/config/config.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index c6085538a0..c0364e29ae 100644 --- a/.env +++ b/.env @@ -145,7 +145,7 @@ SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/api/pyproject.toml b/api/pyproject.toml index 5e18ffac20..2b34eb6e19 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -68,7 +68,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.31.0" +version = "1.32.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index d2a30ca897..18566c5c71 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.31.0 + version: 1.32.0 description: |- Prowler API specification. diff --git a/api/uv.lock b/api/uv.lock index 9689a9cd0a..bb42355e20 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4499,7 +4499,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.31.0" +version = "1.32.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index dea9c09446..eea9075cda 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.29.0" -PROWLER_API_VERSION="5.29.0" +PROWLER_UI_VERSION="5.30.0" +PROWLER_API_VERSION="5.30.0" ``` diff --git a/prowler/config/config.py b/prowler/config/config.py index 71c59d82a3..9e2b079da6 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.30.0" +prowler_version = "5.31.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/pyproject.toml b/pyproject.toml index 28a4b531cc..41fec77a42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,7 +124,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.13" -version = "5.30.0" +version = "5.31.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/uv.lock b/uv.lock index 3a66d2826b..be25aea7da 100644 --- a/uv.lock +++ b/uv.lock @@ -3245,7 +3245,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.30.0" +version = "5.31.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, From 610febb5d56ed9e1d008bde9b43ff5f132d276a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 11 Jun 2026 15:53:44 +0200 Subject: [PATCH 050/129] fix(api): bump prowler SDK lock to v5.30.0 for okta_idaas_stig (#11553) --- api/uv.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index bb42355e20..d36993b516 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4415,8 +4415,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.27.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +version = "5.30.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f1d741214a60df17158c3fdc97804fd1fde64f3a" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4489,9 +4489,14 @@ dependencies = [ { name = "pygithub" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "scaleway" }, { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -5531,6 +5536,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, +] + [[package]] name = "statsd" version = "4.0.1" From ce27053c2dd2e97b7589402c83bcb78bb08d41e1 Mon Sep 17 00:00:00 2001 From: Zeus Almightee <53497290+ernestprovo23@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:53:28 -0400 Subject: [PATCH 051/129] feat(aws): add securityhub + config org-wide delegated admin checks (#11259) Co-authored-by: Lydia Vilchez --- prowler/CHANGELOG.md | 9 + .../__init__.py | 0 ...d_org_aggregator_all_regions.metadata.json | 44 ++ ...ed_admin_and_org_aggregator_all_regions.py | 115 ++++ .../aws/services/config/config_service.py | 131 +++++ .../__init__.py | 0 ...ed_admin_enabled_all_regions.metadata.json | 44 ++ ...hub_delegated_admin_enabled_all_regions.py | 84 +++ .../securityhub/securityhub_service.py | 101 +++- ...min_and_org_aggregator_all_regions_test.py | 491 +++++++++++++++++ ...elegated_admin_enabled_all_regions_test.py | 512 ++++++++++++++++++ 11 files changed, 1530 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json create mode 100644 prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json create mode 100644 prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py create mode 100644 tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py create mode 100644 tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bb206f5a96..62fbb35d30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.31.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) + +--- + ## [5.30.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json new file mode 100644 index 0000000000..cbbd7a66dd --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "config_delegated_admin_and_org_aggregator_all_regions", + "CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "config", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsConfigConfigurationAggregator", + "ResourceGroup": "governance", + "Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.", + "Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html", + "https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws organizations register-delegated-administrator --account-id --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=,AllAwsRegions=true", + "NativeIaC": "", + "Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.", + "Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "config_recorder_all_regions_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py new file mode 100644 index 0000000000..f56ea191f8 --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.config.config_client import config_client +from prowler.providers.aws.services.config.config_service import Aggregator + + +class config_delegated_admin_and_org_aggregator_all_regions(Check): + """Ensure AWS Config has a delegated admin and an org aggregator covering all regions. + + This check verifies that: + 1. A delegated administrator is registered for the config.amazonaws.com + service principal via AWS Organizations. + 2. At least one AWS Config Configuration Aggregator exists with an + OrganizationAggregationSource that covers all AWS regions + (AllAwsRegions=true). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. One finding per + aggregator-region, or a single synthetic FAIL when no aggregators + exist in any region. + """ + findings = [] + + has_delegated_admin = ( + bool(config_client.delegated_administrators) + and not config_client.delegated_administrators_lookup_failed + ) + delegated_admin_unknown = config_client.delegated_administrators_lookup_failed + + # No aggregators in any region: emit one synthetic FAIL anchored to the + # audited account in the default region. + if not config_client.aggregators: + synthetic = Aggregator( + name="unknown", + arn=config_client.get_unknown_arn( + region=config_client.region, + resource_type="config-aggregator", + ), + region=config_client.region, + all_aws_regions=False, + aws_regions=None, + organization_aggregation_source_present=False, + ) + report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic) + if delegated_admin_unknown: + delegated_state = ( + "delegated administrator status could not be determined" + ) + elif has_delegated_admin: + delegated_state = "delegated administrator configured" + else: + delegated_state = ( + "no delegated administrator registered for config.amazonaws.com" + ) + report.status = "FAIL" + report.status_extended = ( + f"AWS Config has no Organization Aggregator configured in any " + f"region ({delegated_state})." + ) + findings.append(report) + return findings + + for region, aggregators_in_region in config_client.aggregators.items(): + for aggregator in aggregators_in_region: + report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator) + + org_aware = aggregator.organization_aggregation_source_present + covers_all = aggregator.all_aws_regions + + issues = [] + if delegated_admin_unknown: + issues.append( + "delegated administrator status for config.amazonaws.com " + "could not be determined" + ) + elif not has_delegated_admin: + issues.append( + "no delegated administrator registered for config.amazonaws.com" + ) + if not org_aware: + issues.append( + f"aggregator {aggregator.name} is not an organization aggregator" + ) + elif not covers_all: + issues.append( + f"aggregator {aggregator.name} does not cover all AWS regions" + ) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} has issues: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} is an organization aggregator covering all " + f"AWS regions with delegated admin configured." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + config_client.audit_config.get("mute_non_default_regions", False) + and region != config_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index 443cee2233..85ab22fd8e 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,10 +13,16 @@ class Config(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.recorders = {} + self.aggregators: dict[str, list] = {} + self.delegated_administrators: list = [] + self.delegated_administrators_lookup_failed: bool = False self.__threading_call__(self.describe_configuration_recorders) self.__threading_call__( self._describe_configuration_recorder_status, self.recorders.values() ) + self.__threading_call__(self._describe_configuration_aggregators) + # Organizations API is not regional; single call. + self._list_config_delegated_administrators() def _get_recorder_arn_template(self, region): return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder" @@ -73,6 +80,108 @@ class Config(AWSService): f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_configuration_aggregators(self, regional_client): + """Describe AWS Config configuration aggregators per region. + + An aggregator counts as organization-aware when its + OrganizationAggregationSource key is present in the response. + """ + logger.info("Config - Describing Configuration Aggregators...") + try: + paginator = regional_client.get_paginator( + "describe_configuration_aggregators" + ) + region_aggregators: list = [] + for page in paginator.paginate(): + for aggregator in page.get("ConfigurationAggregators", []): + name = aggregator.get("ConfigurationAggregatorName", "") + arn = aggregator.get("ConfigurationAggregatorArn", "") + org_source = aggregator.get("OrganizationAggregationSource") + org_aware = org_source is not None + all_aws_regions = False + aws_regions: Optional[list] = None + if org_aware: + all_aws_regions = org_source.get("AllAwsRegions", False) + aws_regions = org_source.get("AwsRegions") + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + region_aggregators.append( + Aggregator( + name=name, + arn=arn, + region=regional_client.region, + all_aws_regions=all_aws_regions, + aws_regions=aws_regions, + organization_aggregation_source_present=org_aware, + ) + ) + if region_aggregators: + self.aggregators[regional_client.region] = region_aggregators + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "AccessDenied", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_config_delegated_administrators(self): + """List delegated administrators for the AWS Config service principal. + + Uses the Organizations API directly (not regional). Sets + delegated_administrators_lookup_failed to True on AccessDenied so callers + can surface the unknown delegated-admin state in findings. + """ + logger.info( + "Config - Listing delegated administrators for config.amazonaws.com..." + ) + try: + org_client = self.session.client("organizations") + paginator = org_client.get_paginator("list_delegated_administrators") + for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"): + for admin in page.get("DelegatedAdministrators", []): + self.delegated_administrators.append( + ConfigDelegatedAdministrator( + id=admin.get("Id", ""), + arn=admin.get("Arn", ""), + name=admin.get("Name", ""), + email=admin.get("Email", ""), + status=admin.get("Status", ""), + joined_method=admin.get("JoinedMethod", ""), + ) + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code in ( + "AccessDeniedException", + "AccessDenied", + "AWSOrganizationsNotInUseException", + ): + self.delegated_administrators_lookup_failed = True + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Recorder(BaseModel): name: str @@ -80,3 +189,25 @@ class Recorder(BaseModel): recording: Optional[bool] last_status: Optional[str] region: str + + +class Aggregator(BaseModel): + """Represents an AWS Config Configuration Aggregator.""" + + name: str + arn: str + region: str + all_aws_regions: bool = False + aws_regions: Optional[list] = None + organization_aggregation_source_present: bool = False + + +class ConfigDelegatedAdministrator(BaseModel): + """Represents a delegated administrator registered for config.amazonaws.com.""" + + id: str + arn: str + name: str + email: str + status: str + joined_method: str diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..99748671ae --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "securityhub_delegated_admin_enabled_all_regions", + "CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "securityhub", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSecurityHubHub", + "ResourceGroup": "security", + "Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.", + "Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws securityhub enable-organization-admin-account --admin-account-id && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.", + "Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "securityhub_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..4828752183 --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.securityhub.securityhub_client import ( + securityhub_client, +) + + +class securityhub_delegated_admin_enabled_all_regions(Check): + """Ensure Security Hub has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for Security Hub + 2. Security Hub is active (ACTIVE status) in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in securityhub_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + admin_lookup_failed = securityhub_client.organization_admin_lookup_failed + + for securityhub in securityhub_client.securityhubs: + report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub) + + # Check if this region has a delegated admin + has_delegated_admin = securityhub.region in regions_with_admin + + # Check if hub is active + hub_active = securityhub.status == "ACTIVE" + + # Check if auto-enable is configured for organization members + auto_enable_on = securityhub.organization_auto_enable + + # Determine overall status + issues = [] + if admin_lookup_failed: + issues.append("delegated administrator status could not be determined") + elif not has_delegated_admin: + issues.append("no delegated administrator configured") + if not hub_active: + issues.append("Security Hub not enabled") + if ( + hub_active + and securityhub.organization_config_available + and not auto_enable_on + ): + # Only report auto-enable issue if hub is active and org config data + # is available (i.e., we could actually read AutoEnable from the API). + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has delegated " + f"admin configured with hub active and organization auto-enable " + f"enabled." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + securityhub_client.audit_config.get("mute_non_default_regions", False) + and securityhub.region != securityhub_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 0799c6e048..5e2d445742 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -13,8 +13,14 @@ class SecurityHub(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.securityhubs = [] + self.organization_admin_accounts = [] + self.organization_admin_lookup_failed: bool = False self.__threading_call__(self._describe_hub) self.__threading_call__(self._list_tags, self.securityhubs) + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.securityhubs + ) def _describe_hub(self, regional_client): logger.info("SecurityHub - Describing Hub...") @@ -104,6 +110,95 @@ class SecurityHub(AWSService): f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List Security Hub delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("SecurityHub - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + self.organization_admin_lookup_failed = True + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.organization_admin_lookup_failed = True + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, securityhub): + """Describe the organization configuration for a Security Hub instance. + + This provides information about auto-enable settings for the organization. + Only invoked for hubs in ACTIVE status. + """ + logger.info("SecurityHub - describing organization configuration...") + try: + if securityhub.status != "ACTIVE": + return + regional_client = self.regional_clients[securityhub.region] + org_config = regional_client.describe_organization_configuration() + securityhub.organization_auto_enable = org_config.get("AutoEnable", False) + securityhub.auto_enable_standards = org_config.get( + "AutoEnableStandards", "NONE" + ) + securityhub.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a Security Hub delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class SecurityHubHub(BaseModel): arn: str @@ -112,4 +207,8 @@ class SecurityHubHub(BaseModel): standards: str integrations: str region: str - tags: Optional[list] + tags: Optional[list] = [] + # Organization configuration fields + organization_auto_enable: bool = False + auto_enable_standards: str = "NONE" + organization_config_available: bool = False diff --git a/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py new file mode 100644 index 0000000000..f2acf555d1 --- /dev/null +++ b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py @@ -0,0 +1,491 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +AGG_ARN_TEMPLATE = ( + "arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}" +) + + +def _aggregator_payload( + name, region, *, org_aware=True, all_regions=True, aws_regions=None +): + payload = { + "ConfigurationAggregatorName": name, + "ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name), + } + if org_aware: + org_source = { + "RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations", + "AllAwsRegions": all_regions, + } + if not all_regions and aws_regions: + org_source["AwsRegions"] = aws_regions + payload["OrganizationAggregationSource"] = org_source + return payload + + +def make_mock_no_aggregators_no_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return {"ConfigurationAggregators": []} + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregator_not_org_aware(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "legacy-agg", + AWS_REGION_EU_WEST_1, + org_aware=False, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_org_aggregator_not_all_regions_with_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "partial-org-agg", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=False, + aws_regions=[AWS_REGION_EU_WEST_1], + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_full_pass(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_access_denied_on_orgs(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators", + } + }, + operation_name, + ) + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_access_denied(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "denied", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_other_client_error(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InternalServerError", + "Message": "boom", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise RuntimeError("simulated transient error") + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_delegated_admins_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + return _mock + + +class Test_config_delegated_admin_and_org_aggregator_all_regions: + @mock_aws + def test_no_aggregators_no_admin(self): + """Test when no aggregators exist in any region and no delegated admin is set.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_no_aggregators_no_admin(), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "no Organization Aggregator configured in any region" + in result[0].status_extended + ) + assert ( + "no delegated administrator registered for config.amazonaws.com" + in result[0].status_extended + ) + + @mock_aws + def test_aggregator_not_org_aware(self): + """Test when an aggregator exists but is not an organization aggregator.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregator_not_org_aware(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "is not an organization aggregator" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_org_aggregator_not_all_regions_with_admin(self): + """Test org aggregator that doesn't cover all AWS regions (delegated admin set).""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_org_aggregator_not_all_regions_with_admin(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "does not cover all AWS regions" in eu_west_1_result.status_extended + ) + + @mock_aws + def test_full_pass(self): + """Test PASS: delegated admin set and org aggregator covering all AWS regions.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_full_pass(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert ( + "is an organization aggregator covering all AWS regions" + in eu_west_1_result.status_extended + ) + assert "delegated admin configured" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format( + region=AWS_REGION_EU_WEST_1, name="org-aggregator" + ) + + @mock_aws + def test_access_denied_on_organizations(self): + """Test that AccessDenied on Organizations is reported as unknown admin state.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_access_denied_on_orgs(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + # The check still runs; aggregator coverage is satisfied but the + # delegated-admin status is unknown, so it must FAIL. + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status for config.amazonaws.com could not be determined" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_aggregators_access_denied(self): + """AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_access_denied(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_other_client_error(self): + """Non-access ClientError on DescribeConfigurationAggregators is logged at error level.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_other_client_error(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_unexpected_exception(self): + """Non-ClientError on DescribeConfigurationAggregators is caught by bare except.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_delegated_admins_unexpected_exception(self): + """Non-ClientError on ListDelegatedAdministrators must still set lookup_failed.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_delegated_admins_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.delegated_administrators_lookup_failed is True + assert service.delegated_administrators == [] diff --git a/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..01af147e9a --- /dev/null +++ b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,512 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + +HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default" + + +def _active_hub_responses(operation_name): + """Return a moto-friendly response for hub-describing API calls. + + Returns None if the operation is not one of the hub APIs (so the caller + can fall back to the default behavior). + """ + if operation_name == "DescribeHub": + return { + "HubArn": HUB_ARN, + "SubscribedAt": "2024-01-01T00:00:00.000Z", + "AutoEnableControls": True, + } + if operation_name == "GetEnabledStandards": + return {"StandardsSubscriptions": []} + if operation_name == "ListEnabledProductsForImport": + return {"ProductSubscriptions": []} + if operation_name == "ListTagsForResource": + return {"Tags": {}} + return None + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs - PASS scenario.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": True, + "AutoEnableStandards": "DEFAULT", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params): + """Simulate Security Hub not subscribed in the account (InvalidAccessException).""" + if operation_name == "DescribeHub": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InvalidAccessException", + "Message": "Account is not subscribed to AWS Security Hub", + } + }, + operation_name, + ) + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params): + """Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts", + } + }, + operation_name, + ) + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params): + """ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise RuntimeError("simulated transient error") + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_other_client_error( + self, operation_name, api_params +): + """DescribeOrganizationConfiguration raises a non-access ClientError — else branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "InternalServerError", "Message": "boom"}}, + operation_name, + ) + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params): + """DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + +class Test_securityhub_delegated_admin_enabled_all_regions: + def teardown_method(self): + """Evict cached securityhub modules so legacy mock.patch-based tests + in the same session see a fresh import path.""" + import sys + + for mod in ( + "prowler.providers.aws.services.securityhub.securityhub_client", + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions", + ): + sys.modules.pop(mod, None) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_securityhub_not_subscribed, + ) + @mock_aws + def test_no_securityhub(self): + """Test when Security Hub is not subscribed in any region.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with NOT_AVAILABLE hubs) + assert len(result) > 0 + # All should fail since hub is not enabled + for finding in result: + assert finding.status == "FAIL" + assert "Security Hub not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_securityhub_enabled_no_delegated_admin(self): + """Test when Security Hub is enabled but no delegated admin is configured.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_securityhub_enabled_with_admin_no_auto_enable(self): + """Test when Security Hub is enabled with delegated admin but auto-enable is off.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_securityhub_enabled_with_admin_and_auto_enable(self): + """Test when Security Hub is enabled with delegated admin and auto-enable on (PASS).""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_access_denied, + ) + @mock_aws + def test_admin_lookup_access_denied(self): + """AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in eu_west_1_result.status_extended + ) + assert ( + "no delegated administrator configured" + not in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_unexpected, + ) + @mock_aws + def test_admin_lookup_unexpected_exception(self): + """Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.organization_admin_lookup_failed is True + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in result[0].status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_other_client_error, + ) + @mock_aws + def test_describe_org_config_other_client_error(self): + """Non-access ClientError on DescribeOrganizationConfiguration is logged at error level.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + # organization_config_available stays False, so the auto-enable issue is suppressed + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + # Admin is configured and hub is active; with org config unavailable the + # check should PASS because there are no other detectable issues. + assert result and result[0].status == "PASS" + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_unexpected, + ) + @mock_aws + def test_describe_org_config_unexpected_exception(self): + """Non-ClientError on DescribeOrganizationConfiguration is caught by bare except.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "PASS" From 65f00a197bbbe203f068d854d24935f2265deb3d Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:32:28 +0200 Subject: [PATCH 052/129] fix(api): normalize OCI scan region credentials (#11558) --- api/CHANGELOG.md | 8 ++++++++ api/src/backend/api/tests/test_utils.py | 24 ++++++++++++++++++++++++ api/src/backend/api/utils.py | 6 ++++++ 3 files changed, 38 insertions(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 8f3585fd20..1756fe83a4 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.31.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + ## [1.31.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 3a7f030a9c..3fd9235dac 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -357,6 +357,30 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set( + self, + ): + secret_dict = { + "user": "ocid1.user.oc1..fake", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + "tenancy": "ocid1.tenancy.oc1..fake", + "region": "us-ashburn-1", + "pass_phrase": "fake-passphrase", + } + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret = secret_mock + provider.uid = "ocid1.tenancy.oc1..fake" + + result = get_prowler_provider_kwargs(provider) + + expected_result = {**secret_dict, "region": {"us-ashburn-1"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): provider_uid = "provider_uid" secret_dict = {"key": "value"} diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8da8d91226..678ed24772 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -243,6 +243,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. From bba594a1db82d0cd13610b815e8be0c4739d4776 Mon Sep 17 00:00:00 2001 From: Oleksandr_Sanin Date: Thu, 11 Jun 2026 17:40:41 +0200 Subject: [PATCH 053/129] feat(aws/sagemaker): add sagemaker_clarify_exists check (#11211) Signed-off-by: Oleksandr Sanin Signed-off-by: Oleksandr Yizchak Sanin Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../sagemaker_clarify_exists/__init__.py | 0 .../sagemaker_clarify_exists.metadata.json | 39 +++ .../sagemaker_clarify_exists.py | 54 ++++ .../services/sagemaker/sagemaker_service.py | 88 +++++++ .../sagemaker_clarify_exists_test.py | 247 ++++++++++++++++++ .../sagemaker/sagemaker_service_test.py | 4 +- 7 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json create mode 100644 prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py create mode 100644 tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 62fbb35d30..eb81a58378 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) - `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) --- diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json new file mode 100644 index 0000000000..0e127a7759 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_clarify_exists", + "CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.", + "Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri= --role-arn --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.", + "Url": "https://hub.prowler.com/check/sagemaker_clarify_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py new file mode 100644 index 0000000000..9c559613d3 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_clarify_exists(Check): + """Check whether at least one SageMaker Clarify processing job exists per region. + + A region is reported only when ListProcessingJobs succeeded for it; regions + where the API call failed (e.g. AccessDenied, unsupported region) are + skipped at the service layer and produce no finding. + + - PASS: At least one processing job uses the AWS-managed Clarify container + image in the region (one finding per job). + - FAIL: No processing job uses the Clarify container image in the region + (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker Clarify exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(sagemaker_client.processing_jobs_scanned_regions): + clarify_jobs = sorted( + ( + job + for job in sagemaker_client.sagemaker_processing_jobs + if job.region == region + and job.image_uri + and "sagemaker-clarify-processing" in job.image_uri + ), + key=lambda job: job.name, + ) + + if clarify_jobs: + for job in clarify_jobs: + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "sagemaker-clarify" + report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job" + report.status = "FAIL" + report.status_extended = ( + f"No SageMaker Clarify processing jobs found in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 5093a4ac02..20ea4c0280 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,6 +15,8 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + self.sagemaker_processing_jobs = [] + self.processing_jobs_scanned_regions = set() self.sagemaker_domains = [] self.endpoint_configs = {} self.sagemaker_model_registries = [] @@ -24,6 +26,7 @@ class SageMaker(AWSService): self.__threading_call__(self._list_notebook_instances) self.__threading_call__(self._list_models) self.__threading_call__(self._list_training_jobs) + self.__threading_call__(self._list_processing_jobs) self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) self.__threading_call__(self._list_model_package_groups) @@ -37,6 +40,9 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) + self.__threading_call__( + self._describe_processing_job, self.sagemaker_processing_jobs + ) self.__threading_call__( self._describe_endpoint_config, list(self.endpoint_configs.values()) ) @@ -51,6 +57,9 @@ class SageMaker(AWSService): self.__threading_call__( self._list_tags_for_resource, self.sagemaker_training_jobs ) + self.__threading_call__( + self._list_tags_for_resource, self.sagemaker_processing_jobs + ) self.__threading_call__( self._list_tags_for_resource, list(self.endpoint_configs.values()) ) @@ -128,6 +137,66 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_processing_jobs(self, regional_client): + """List SageMaker processing jobs in a region. + + Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob` + entries and adds ``regional_client.region`` to + ``self.processing_jobs_scanned_regions`` once pagination succeeds, so + regions where ``ListProcessingJobs`` fails are skipped by checks that + consume that set. + + Args: + regional_client: Regional SageMaker boto3 client. + """ + logger.info("SageMaker - listing processing jobs...") + try: + list_processing_jobs_paginator = regional_client.get_paginator( + "list_processing_jobs" + ) + for page in list_processing_jobs_paginator.paginate(): + for processing_job in page["ProcessingJobSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + processing_job["ProcessingJobArn"], self.audit_resources + ) + ): + self.sagemaker_processing_jobs.append( + ProcessingJob( + name=processing_job["ProcessingJobName"], + region=regional_client.region, + arn=processing_job["ProcessingJobArn"], + ) + ) + self.processing_jobs_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_processing_job(self, processing_job): + """Describe a SageMaker processing job and enrich its image metadata. + + Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and + stores it on ``processing_job.image_uri``. Errors are logged and + swallowed so a failure in one job does not abort the scan. + + Args: + processing_job: ProcessingJob model to enrich in-place. + """ + logger.info("SageMaker - describing processing job...") + try: + regional_client = self.regional_clients[processing_job.region] + describe_processing_job = regional_client.describe_processing_job( + ProcessingJobName=processing_job.name + ) + app_spec = describe_processing_job.get("AppSpecification", {}) + processing_job.image_uri = app_spec.get("ImageUri") + except Exception as error: + logger.error( + f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _describe_notebook_instance(self, notebook_instance): logger.info("SageMaker - describing notebook instances...") try: @@ -451,6 +520,25 @@ class TrainingJob(BaseModel): tags: Optional[list] = [] +class ProcessingJob(BaseModel): + """Represents a SageMaker processing job. + + Attributes: + name: Processing job name. + region: AWS region where the job lives. + arn: Processing job ARN. + image_uri: Container image URI from `AppSpecification.ImageUri`, + populated by `_describe_processing_job`. + tags: Resource tags, populated by `_list_tags_for_resource`. + """ + + name: str + region: str + arn: str + image_uri: Optional[str] = None + tags: Optional[list] = [] + + class ProductionVariant(BaseModel): name: str initial_instance_count: int diff --git a/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py new file mode 100644 index 0000000000..32f5487100 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py @@ -0,0 +1,247 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0" +NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0" +CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0" +PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job" + + +class Test_sagemaker_clarify_exists: + def test_no_processing_jobs_no_scanned_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = set() + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_processing_jobs_region_scanned(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "sagemaker-clarify" + + def test_non_clarify_processing_job(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="xgboost-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job", + region=AWS_REGION_US_EAST_1, + image_uri=NON_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_custom_image_with_clarify_in_name_does_not_match(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="my-clarify-thing-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job", + region=AWS_REGION_US_EAST_1, + image_uri=CUSTOM_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_clarify_processing_job_exists(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "clarify-job" + assert result[0].resource_arn == PROCESSING_JOB_ARN + + def test_mixed_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = { + AWS_REGION_US_EAST_1, + AWS_REGION_EU_WEST_1, + } + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index d49a506fd9..50431c2e13 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -396,13 +396,13 @@ class Test_SageMaker_Service: sagemaker_service = SageMaker(audit_info) # Check that __threading_call__ was called for _list_tags_for_resource - # (one for each resource type: models, notebooks, training jobs, endpoint configs, domains) + # (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains) tag_calls = [ c for c in mock_threading_call.call_args_list if c[0][0] == sagemaker_service._list_tags_for_resource ] - assert len(tag_calls) == 5 + assert len(tag_calls) == 6 # Test SageMaker list model package groups def test_list_model_package_groups(self): From 20eca787676c44f383799d1700864c7d5f1a2115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 11 Jun 2026 18:00:36 +0200 Subject: [PATCH 054/129] fix(compliance): resolve provider from scan in attributes endp (#11546) --- api/CHANGELOG.md | 1 + api/src/backend/api/tests/test_views.py | 182 ++++++++++++++++++ api/src/backend/api/v1/views.py | 57 +++++- ui/CHANGELOG.md | 8 + ui/actions/compliances/compliances.ts | 11 +- .../compliance/[compliancetitle]/page.tsx | 2 +- 6 files changed, 258 insertions(+), 3 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 1756fe83a4..fc367813f7 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) - OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) --- diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ab90bdbde0..410bd23e19 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9570,6 +9570,188 @@ class TestComplianceOverviewViewSet: assert "Category" in first_attr assert "AWSService" in first_attr + def test_compliance_overview_attributes_resolves_provider_from_scan( + self, authenticated_client, tenants_fixture, providers_fixture + ): + # csa_ccm_4.0 is a multi-provider universal framework: a single + # compliance_id whose requirements expose different checks per provider. + # Passing a scan must return the check IDs for that scan's provider, + # otherwise the endpoint defaults to the first provider that declares the + # framework and azure/gcp requirements end up with check IDs that match + # no findings. + tenant = tenants_fixture[0] + gcp_provider = providers_fixture[2] + azure_provider = providers_fixture[4] + assert gcp_provider.provider == Provider.ProviderChoices.GCP.value + assert azure_provider.provider == Provider.ProviderChoices.AZURE.value + + now = datetime.now(timezone.utc) + gcp_scan = Scan.objects.create( + name="gcp scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + azure_scan = Scan.objects.create( + name="azure scan", + provider=azure_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id=None): + params = {"filter[compliance_id]": "csa_ccm_4.0"} + if scan_id is not None: + params["filter[scan_id]"] = str(scan_id) + return authenticated_client.get( + reverse("complianceoverview-attributes"), params + ) + + def collect_check_ids(scan_id=None): + response = request_attributes(scan_id) + assert response.status_code == status.HTTP_200_OK + check_ids = set() + for item in response.json()["data"]: + check_ids.update(item["attributes"]["attributes"]["check_ids"]) + return check_ids + + gcp_check_ids = collect_check_ids(gcp_scan.id) + azure_check_ids = collect_check_ids(azure_scan.id) + + # Each scan resolves to its own provider's checks, and they differ. + assert gcp_check_ids + assert azure_check_ids + assert gcp_check_ids != azure_check_ids + + # The returned check IDs belong to the SDK's per-provider definition. + from api.compliance import get_prowler_provider_compliance + + def expected_check_ids(provider_type): + framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"] + expected = set() + for requirement in framework.requirements: + expected.update(requirement.checks.get(provider_type, [])) + return expected + + assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value) + assert azure_check_ids <= expected_check_ids( + Provider.ProviderChoices.AZURE.value + ) + + # An explicit scan_id is authoritative: a non-existent scan must fail + # closed with 404 instead of silently falling back to another provider. + missing_response = request_attributes("00000000-0000-0000-0000-000000000000") + assert missing_response.status_code == status.HTTP_404_NOT_FOUND + + # A malformed scan_id is rejected with 404 as well. + malformed_response = request_attributes("not-a-uuid") + assert malformed_response.status_code == status.HTTP_404_NOT_FOUND + + # An empty value (filter[scan_id]=) must not fall back to the legacy + # provider picker: the explicit (if blank) selector fails closed. + empty_response = request_attributes("") + assert empty_response.status_code == status.HTTP_404_NOT_FOUND + + # A scan belonging to another tenant is not visible (RLS), so it must + # return 404 rather than leaking the fallback provider's check IDs. + other_tenant = Tenant.objects.create(name="Other Compliance Tenant") + foreign_provider = Provider.objects.create( + provider="gcp", + uid="foreign-gcp-test", + alias="foreign_gcp", + tenant_id=other_tenant.id, + ) + foreign_scan = Scan.objects.create( + name="foreign scan", + provider=foreign_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=other_tenant.id, + started_at=now, + completed_at=now, + ) + foreign_response = request_attributes(foreign_scan.id) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + def test_compliance_overview_attributes_scan_scoped_by_provider_group( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + ): + # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be + # able to resolve scans for providers in its provider groups. Tenant RLS + # alone is not enough here: both scans belong to the same tenant, so the + # endpoint has to scope the scan lookup by provider group, otherwise a + # restricted user could read another provider's compliance metadata. + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + assert allowed_provider.provider == Provider.ProviderChoices.GCP.value + assert denied_provider.provider == Provider.ProviderChoices.AZURE.value + + provider_group = ProviderGroup.objects.create( + name="limited-compliance-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + now = datetime.now(timezone.utc) + allowed_scan = Scan.objects.create( + name="allowed scan", + provider=allowed_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + denied_scan = Scan.objects.create( + name="denied scan", + provider=denied_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id): + return client.get( + reverse("complianceoverview-attributes"), + { + "filter[compliance_id]": "csa_ccm_4.0", + "filter[scan_id]": str(scan_id), + }, + ) + + # The scan in the user's provider group resolves normally. + assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK + + # The scan outside the user's provider group is invisible, so it fails + # closed with 404 instead of leaking the other provider's check IDs. + assert ( + request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND + ) + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 45100f1018..0942adc8e1 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg from django.contrib.postgres.search import SearchQuery +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import ( BooleanField, @@ -4644,6 +4645,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -5084,7 +5095,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 67a0a59e5d..277fabb5b0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.30.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) + +--- + ## [1.30.0] (Prowler v5.30.0) ### 🚀 Added diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 58e7c5b5a0..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -73,12 +73,21 @@ export const getComplianceOverviewMetadataInfo = async ({ } }; -export const getComplianceAttributes = async (complianceId: string) => { +export const getComplianceAttributes = async ( + complianceId: string, + scanId?: string, +) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); url.searchParams.append("filter[compliance_id]", complianceId); + // Pass the scan so multi-provider universal frameworks (e.g. CSA CCM) + // resolve the check IDs for the scan's provider instead of defaulting to + // the first provider that declares the framework. + if (scanId) { + url.searchParams.append("filter[scan_id]", scanId); + } const response = await fetch(url.toString(), { headers, diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 445710e48f..f5d42f8bc8 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -87,7 +87,7 @@ export default async function ComplianceDetail({ "filter[scan_id]": selectedScanId ?? undefined, }, }), - getComplianceAttributes(complianceId), + getComplianceAttributes(complianceId, selectedScanId ?? undefined), selectedScanId ? getScan(selectedScanId, { include: "provider" }) : Promise.resolve(null), From a394c0fdf68e176d2cd3c3121551df3040983454 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Thu, 11 Jun 2026 18:32:35 +0200 Subject: [PATCH 055/129] fix(api): drop_subgraph deletes relationships then nodes to cut Neo4j memory (#11557) --- api/CHANGELOG.md | 2 + api/src/backend/api/attack_paths/database.py | 20 ++++- .../api/tests/test_attack_paths_database.py | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index fc367813f7..5b5ea6b97e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,9 +4,11 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.31.1] (Prowler UNRELEASED) + ### 🐞 Fixed - `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) - OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) --- diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index d5cc1698a7..0e6cc083dc 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -175,7 +175,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: """ Delete all nodes for a provider from the tenant database. - Uses batched deletion to avoid memory issues with large graphs. + Deletes relationships then nodes in batches (not `DETACH DELETE`) so a dense + provider's graph cannot exceed Neo4j's transaction memory limit. Silently returns 0 if the database doesn't exist. """ provider_label = get_provider_label(provider_id) @@ -183,13 +184,28 @@ def drop_subgraph(database: str, provider_id: str) -> int: try: with get_session(database) as session: + # Phase 1: delete relationships incident to provider nodes in batches. + deleted_count = 1 + while deleted_count > 0: + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_rels_count", 0) + + # Phase 2: delete the now relationship-free nodes in batches. deleted_count = 1 while deleted_count > 0: result = session.run( f""" MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size - DETACH DELETE n + DELETE n RETURN COUNT(n) AS deleted_nodes_count """, {"batch_size": BATCH_SIZE}, diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 3a29a1007d..7ca8a4accb 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -542,3 +542,84 @@ class TestHasProviderData: ): with pytest.raises(db_module.GraphDatabaseQueryException): db_module.has_provider_data("db-tenant-abc", "provider-123") + + +class TestDropSubgraph: + """Test drop_subgraph two-phase batched deletion of a provider's graph.""" + + @staticmethod + def _result(count): + result = MagicMock() + result.single.return_value.get.return_value = count + return result + + @staticmethod + def _session_ctx(session): + ctx = MagicMock() + ctx.__enter__.return_value = session + ctx.__exit__.return_value = False + return ctx + + def test_deletes_relationships_then_nodes_in_batches(self): + session = MagicMock() + # Phase 1 (relationships): one full batch then empty. + # Phase 2 (nodes): one full batch then empty. + session.run.side_effect = [ + self._result(1000), + self._result(0), + self._result(1000), + self._result(0), + ] + + with patch( + "api.attack_paths.database.get_session", + return_value=self._session_ctx(session), + ): + deleted = db_module.drop_subgraph("db-tenant-abc", "provider-123") + + # Only phase-2 node counts contribute to the return value. + assert deleted == 1000 + assert session.run.call_count == 4 + + queries = [call.args[0] for call in session.run.call_args_list] + + # Regression guard: the memory blow-up was caused by DETACH DELETE. + assert all("DETACH DELETE" not in query for query in queries) + + rel_queries = [query for query in queries if "DELETE r" in query] + node_queries = [query for query in queries if "DELETE n" in query] + assert rel_queries and node_queries + # DISTINCT avoids double-counting relationships matched from both ends. + assert all("DISTINCT r" in query for query in rel_queries) + + # Relationships must be fully drained before nodes are deleted. + first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q) + last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q) + assert last_rel < first_node + + def test_returns_zero_when_database_not_found(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Database does not exist", + code="Neo.ClientError.Database.DatabaseNotFound", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + assert db_module.drop_subgraph("db-tenant-gone", "provider-123") == 0 + + def test_raises_on_other_errors(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Connection refused", + code="Neo.TransientError.General.UnknownError", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + with pytest.raises(db_module.GraphDatabaseQueryException): + db_module.drop_subgraph("db-tenant-abc", "provider-123") From 2e82f1564f6b5535baa0692776726c60fc469577 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:07:56 +0200 Subject: [PATCH 056/129] fix(ui): show threat map data for okta and google workspace accounts (#11542) --- ui/CHANGELOG.md | 1 + .../regions/threat-map.adapter.test.ts | 62 +++++++++++++ .../overview/regions/threat-map.adapter.ts | 15 +++ ui/components/graphs/threat-map.test.tsx | 93 +++++++++++++++++++ ui/components/graphs/threat-map.tsx | 14 ++- 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 ui/actions/overview/regions/threat-map.adapter.test.ts create mode 100644 ui/components/graphs/threat-map.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 277fabb5b0..4e5aa06e45 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) - Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) --- diff --git a/ui/actions/overview/regions/threat-map.adapter.test.ts b/ui/actions/overview/regions/threat-map.adapter.test.ts new file mode 100644 index 0000000000..515825bb0a --- /dev/null +++ b/ui/actions/overview/regions/threat-map.adapter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { adaptRegionsOverviewToThreatMap } from "./threat-map.adapter"; +import type { RegionsOverviewResponse } from "./types"; + +function buildRegionsResponse( + rows: Array<{ providerType: string; region: string }>, +): RegionsOverviewResponse { + return { + data: rows.map(({ providerType, region }, index) => ({ + type: "regions-overview", + id: `region-${index}`, + attributes: { + provider_type: providerType, + region, + total: 10, + fail: 4, + muted: 0, + pass: 6, + }, + })), + meta: { version: "v1" }, + }; +} + +describe("adaptRegionsOverviewToThreatMap", () => { + it("maps okta regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "okta", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "okta", + region: "global", + name: "Okta - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); + + it("maps googleworkspace regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "googleworkspace", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "googleworkspace", + region: "global", + name: "Google Workspace - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); +}); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 0b7852b56a..6ee0958aad 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -261,6 +261,19 @@ const ALIBABACLOUD_COORDINATES: Record = { global: { lat: 30.3, lng: 120.2 }, // Global fallback (Hangzhou HQ) }; +// Okta is a SaaS identity platform without user-facing regions +const OKTA_COORDINATES: Record = { + global: { lat: 37.8, lng: -122.4 }, // Global fallback (San Francisco HQ) +}; + +// Google Workspace is a SaaS suite without user-facing regions +const GOOGLEWORKSPACE_COORDINATES: Record< + string, + { lat: number; lng: number } +> = { + global: { lat: 37.4, lng: -122.1 }, // Global fallback (Mountain View HQ) +}; + const PROVIDER_COORDINATES: Record< string, Record @@ -277,6 +290,8 @@ const PROVIDER_COORDINATES: Record< oraclecloud: ORACLECLOUD_COORDINATES, mongodbatlas: MONGODBATLAS_COORDINATES, alibabacloud: ALIBABACLOUD_COORDINATES, + okta: OKTA_COORDINATES, + googleworkspace: GOOGLEWORKSPACE_COORDINATES, }; // Returns [lng, lat] format for D3/GeoJSON compatibility diff --git a/ui/components/graphs/threat-map.test.tsx b/ui/components/graphs/threat-map.test.tsx new file mode 100644 index 0000000000..47d447bddc --- /dev/null +++ b/ui/components/graphs/threat-map.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ThreatMap } from "./threat-map"; +import type { ThreatMapData } from "./threat-map.types"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("./horizontal-bar-chart", () => ({ + HorizontalBarChart: () =>
, +})); + +function buildLocation(providerType: string, region: string) { + return { + id: `${providerType}-${region}`, + name: `${providerType} - ${region}`, + region, + regionCode: region, + providerType, + coordinates: [-122.4, 37.8] as [number, number], + totalFindings: 10, + failFindings: 4, + riskLevel: "high" as const, + severityData: [ + { name: "Fail", value: 4, percentage: 40 }, + { name: "Pass", value: 6, percentage: 60 }, + ], + }; +} + +describe("ThreatMap region selector", () => { + it("auto-selects the region when it is the only one available", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("okta", "global"), + buildLocation("googleworkspace", "global"), + ], + regions: ["global"], + }; + + render(); + + const select = screen.getByRole("combobox", { + name: "Filter threat map by region", + }); + expect(select).toHaveValue("global"); + expect(screen.getByText("Global Regions")).toBeInTheDocument(); + expect( + screen.queryByText("Select a location on the map to view details"), + ).not.toBeInTheDocument(); + }); + + it("keeps All Regions as default when there are multiple regions", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("aws", "us-east-1"), + buildLocation("okta", "global"), + ], + regions: ["global", "us-east-1"], + }; + + render(); + + const select = screen.getByRole("combobox", { + name: "Filter threat map by region", + }); + expect(select).toHaveValue("All Regions"); + expect( + screen.getByRole("option", { name: "All Regions" }), + ).toBeInTheDocument(); + }); + + it("shows the global option capitalized while keeping its filter value", () => { + const data: ThreatMapData = { + locations: [ + buildLocation("aws", "us-east-1"), + buildLocation("okta", "global"), + ], + regions: ["global", "us-east-1"], + }; + + render(); + + const globalOption = screen.getByRole("option", { name: "Global" }); + expect(globalOption).toHaveValue("global"); + expect( + screen.getByRole("option", { name: "us-east-1" }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/graphs/threat-map.tsx b/ui/components/graphs/threat-map.tsx index 42b44aa8d1..b73f81336c 100644 --- a/ui/components/graphs/threat-map.tsx +++ b/ui/components/graphs/threat-map.tsx @@ -124,7 +124,11 @@ export function ThreatMap({ x: number; y: number; } | null>(null); - const [selectedRegion, setSelectedRegion] = useState("All Regions"); + // With a single region "All Regions" adds nothing, so it starts selected + const hasSingleRegion = data.regions.length === 1; + const [selectedRegion, setSelectedRegion] = useState( + hasSingleRegion ? data.regions[0] : "All Regions", + ); const [worldData, setWorldData] = useState(null); const [isLoadingMap, setIsLoadingMap] = useState(true); const [dimensions, setDimensions] = useState<{ @@ -424,10 +428,12 @@ export function ThreatMap({ onChange={(e) => setSelectedRegion(e.target.value)} className="border-border-neutral-primary bg-bg-neutral-secondary text-text-neutral-primary appearance-none rounded-lg border px-4 py-2 pr-10 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" > - + {!hasSingleRegion && ( + + )} {sortedRegions.map((region) => ( ))} @@ -467,7 +473,7 @@ export function ThreatMap({