diff --git a/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md b/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md new file mode 100644 index 0000000000..7a8deb4082 --- /dev/null +++ b/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md @@ -0,0 +1 @@ +`cognito_user_pool_account_recovery_sms_not_preferred` check verifying Cognito user pools do not rank SMS first for account recovery diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/__init__.py b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json new file mode 100644 index 0000000000..60b1b09cce --- /dev/null +++ b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "cognito_user_pool_account_recovery_sms_not_preferred", + "CheckTitle": "Cognito user pool does not prefer SMS for account recovery", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Initial Access/Unauthorized Access" + ], + "ServiceName": "cognito", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "IAM", + "Description": "**Amazon Cognito user pools** do not rank `verified_phone_number` as the first **account recovery** mechanism. The evaluation inspects the `RecoveryMechanisms` of each user pool's `AccountRecoverySetting` and looks at the entry with the lowest `Priority`.", + "Risk": "SMS is the weakest recovery channel: an attacker who performs a **SIM swap** or intercepts **SS7** traffic receives the reset code and takes over the account, bypassing the password. A pool with no `AccountRecoverySetting` is equally exposed, since Cognito then applies **legacy behavior preferring SMS over email**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/cognito/latest/developerguide/how-to-recover-a-user-account.html", + "https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AccountRecoverySettingType.html", + "https://pages.nist.gov/800-63-3/sp800-63b.html#-5122-out-of-band-verifiers" + ], + "Remediation": { + "Code": { + "CLI": "aws cognito-idp update-user-pool --user-pool-id --account-recovery-setting RecoveryMechanisms=[{Priority=1,Name=verified_email}]", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Cognito::UserPool\n Properties:\n AccountRecoverySetting:\n RecoveryMechanisms:\n - Name: verified_email # Critical: email is ranked ahead of SMS\n Priority: 1\n```", + "Other": "1. Open the AWS Management Console and go to Amazon Cognito\n2. Click User pools and select your pool\n3. Go to Sign-in > Account recovery and click Edit\n4. Set Email only (or Admin only) as the recovery method, or move Email above SMS in the priority order\n5. Click Save changes", + "Terraform": "```hcl\nresource \"aws_cognito_user_pool\" \"\" {\n name = \"\"\n\n account_recovery_setting {\n recovery_mechanism {\n name = \"verified_email\" # Critical: email is ranked ahead of SMS\n priority = 1\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set an explicit **account recovery** order that ranks `verified_email` (or `admin_only`) ahead of `verified_phone_number`.\n- Never rely on the **default**, which silently prefers SMS\n- Prefer `admin_only` for privileged pools so recovery requires an operator\n- Require **verified email** on sign-up so the stronger channel is always available", + "Url": "https://hub.prowler.com/check/cognito_user_pool_account_recovery_sms_not_preferred" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py new file mode 100644 index 0000000000..bd04dcc077 --- /dev/null +++ b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cognito.cognito_idp_client import cognito_idp_client + + +class cognito_user_pool_account_recovery_sms_not_preferred(Check): + """Verify that user pools do not prefer SMS for account recovery. + + A pool PASSES when the recovery mechanism with the lowest ``Priority`` is + something other than ``verified_phone_number``. A pool with no + ``AccountRecoverySetting`` at all FAILS, because Amazon Cognito then applies + the legacy behavior that prefers SMS over email. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the Cognito user pool account recovery check. + + Returns: + A list of reports with each user pool preferred recovery mechanism. + """ + findings = [] + for pool in cognito_idp_client.user_pools.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=pool) + mechanisms = (pool.account_recovery_settings or {}).get( + "RecoveryMechanisms", [] + ) + if not mechanisms: + report.status = "FAIL" + report.status_extended = f"User pool {pool.name} does not define an account recovery setting, so SMS is preferred over email." + else: + preferred = min(mechanisms, key=lambda mechanism: mechanism["Priority"]) + if preferred["Name"] == "verified_phone_number": + report.status = "FAIL" + report.status_extended = ( + f"User pool {pool.name} prefers SMS for account recovery." + ) + else: + report.status = "PASS" + report.status_extended = f"User pool {pool.name} prefers {preferred['Name']} over SMS for account recovery." + findings.append(report) + + return findings diff --git a/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py b/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py new file mode 100644 index 0000000000..15c6af875c --- /dev/null +++ b/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py @@ -0,0 +1,171 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +USER_POOL_NAME = "user_pool_name" + +CHECK_PATH = "prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred.cognito_idp_client" +PROVIDER_PATH = "prowler.providers.common.provider.Provider.get_global_provider" + + +class Test_cognito_user_pool_account_recovery_sms_not_preferred: + @mock_aws + def test_no_user_pools(self): + from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=CognitoIDP(aws_provider)): + from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import ( + cognito_user_pool_account_recovery_sms_not_preferred, + ) + + check = cognito_user_pool_account_recovery_sms_not_preferred() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_user_pool_without_account_recovery_setting(self): + from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP + + client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool( + PoolName=USER_POOL_NAME + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + cognito_client = CognitoIDP(aws_provider) + # moto injects a verified_email-first AccountRecoverySetting into every pool, + # so a pool that never had one cannot be produced through the API. Real + # user pools predating the feature return no setting at all, which is the + # case this check has to flag, so clear it here. + for pool in cognito_client.user_pools.values(): + pool.account_recovery_settings = {} + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=cognito_client): + from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import ( + cognito_user_pool_account_recovery_sms_not_preferred, + ) + + check = cognito_user_pool_account_recovery_sms_not_preferred() + result = check.execute() + + pool = list(cognito_client.user_pools.values())[0] + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"User pool {USER_POOL_NAME} does not define an account recovery setting, so SMS is preferred over email." + ) + assert result[0].resource_id == pool.id + assert result[0].resource_arn == pool.arn + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_pool_prefers_sms(self): + from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP + + client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool( + PoolName=USER_POOL_NAME, + AccountRecoverySetting={ + "RecoveryMechanisms": [ + {"Priority": 2, "Name": "verified_email"}, + {"Priority": 1, "Name": "verified_phone_number"}, + ] + }, + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + cognito_client = CognitoIDP(aws_provider) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=cognito_client): + from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import ( + cognito_user_pool_account_recovery_sms_not_preferred, + ) + + check = cognito_user_pool_account_recovery_sms_not_preferred() + result = check.execute() + + pool = list(cognito_client.user_pools.values())[0] + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"User pool {USER_POOL_NAME} prefers SMS for account recovery." + ) + assert result[0].resource_id == pool.id + assert result[0].resource_arn == pool.arn + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_pool_prefers_email_over_sms(self): + from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP + + client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool( + PoolName=USER_POOL_NAME, + AccountRecoverySetting={ + "RecoveryMechanisms": [ + {"Priority": 2, "Name": "verified_phone_number"}, + {"Priority": 1, "Name": "verified_email"}, + ] + }, + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + cognito_client = CognitoIDP(aws_provider) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=cognito_client): + from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import ( + cognito_user_pool_account_recovery_sms_not_preferred, + ) + + check = cognito_user_pool_account_recovery_sms_not_preferred() + result = check.execute() + + pool = list(cognito_client.user_pools.values())[0] + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"User pool {USER_POOL_NAME} prefers verified_email over SMS for account recovery." + ) + assert result[0].resource_id == pool.id + assert result[0].resource_arn == pool.arn + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_user_pool_admin_only_recovery(self): + from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP + + client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool( + PoolName=USER_POOL_NAME, + AccountRecoverySetting={ + "RecoveryMechanisms": [{"Priority": 1, "Name": "admin_only"}] + }, + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + cognito_client = CognitoIDP(aws_provider) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=cognito_client): + from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import ( + cognito_user_pool_account_recovery_sms_not_preferred, + ) + + check = cognito_user_pool_account_recovery_sms_not_preferred() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"User pool {USER_POOL_NAME} prefers admin_only over SMS for account recovery." + )