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 01/68] 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 02/68] 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 03/68] 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 04/68] 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 05/68] 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 06/68] 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 07/68] 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 08/68] 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 09/68] 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 10/68] 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 11/68] 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({