diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a5621db9b7..a4cb0474e9 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232) - `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023) - Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186) +- `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923) ### 🔄 Changed diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json new file mode 100644 index 0000000000..83353b0caa --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "ses_identity_dkim_enabled", + "CheckTitle": "SES identity has DKIM signing enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ses", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "messaging", + "Description": "**Amazon SES identities** are evaluated for **DKIM (DomainKeys Identified Mail)** signing enabled and verified. DKIM adds a cryptographic signature to outgoing emails, allowing recipients to verify that the email was sent by the domain owner and was not altered in transit.", + "Risk": "Without DKIM signing, emails sent from SES identities are vulnerable to **spoofing and tampering**. Attackers can forge emails that appear to come from your domain, leading to phishing attacks, brand impersonation, and loss of email deliverability. Email providers are more likely to reject or mark unsigned emails as spam, impacting business communication and reputation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html", + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sesv2 put-email-identity-dkim-signing-attributes --email-identity --signing-attributes-origin AWS_SES", + "NativeIaC": "", + "Other": "1. In the AWS Console, go to Simple Email Service (SES)\n2. Open Verified identities and select the affected identity\n3. Click the Authentication tab\n4. Under DKIM, click Edit\n5. Enable DKIM signatures and select 'Provide DKIM authentication token (Easy DKIM)'\n6. Save changes and add the provided CNAME records to your DNS provider", + "Terraform": "```hcl\nresource \"aws_ses_domain_dkim\" \"\" {\n domain = \"\"\n}\n\n# Add the CNAME records to Route53 (or your DNS provider)\nresource \"aws_route53_record\" \"_dkim\" {\n count = 3\n zone_id = \"\"\n name = \"${aws_ses_domain_dkim..dkim_tokens[count.index]}._domainkey.\"\n type = \"CNAME\"\n ttl = 600\n records = [\"${aws_ses_domain_dkim..dkim_tokens[count.index]}.dkim.amazonses.com\"]\n}\n```" + }, + "Recommendation": { + "Text": "Enable **DKIM signing** for all SES identities and ensure the DKIM status is **SUCCESS**. Add the required CNAME records to your DNS provider to complete verification. Combine DKIM with **SPF** and **DMARC** for comprehensive email authentication following **defense in depth** principles. Monitor DKIM status regularly and rotate DKIM keys as recommended by AWS.", + "Url": "https://hub.prowler.com/check/ses_identity_dkim_enabled" + } + }, + "Categories": [ + "identity-access", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py new file mode 100644 index 0000000000..2654010e28 --- /dev/null +++ b/prowler/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ses.ses_client import ses_client + + +class ses_identity_dkim_enabled(Check): + def execute(self): + findings = [] + for identity in ses_client.email_identities.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=identity) + if identity.dkim_status == "SUCCESS" and identity.dkim_signing_enabled: + report.status = "PASS" + report.status_extended = f"SES identity {identity.name} has DKIM signing enabled and verified." + elif identity.dkim_status in ( + "PENDING", + "NOT_STARTED", + "TEMPORARY_FAILURE", + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing not verified (status: {identity.dkim_status})." + elif identity.dkim_status == "FAILED": + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM signing failed verification." + elif ( + identity.dkim_status == "SUCCESS" and not identity.dkim_signing_enabled + ): + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} has DKIM verified but signing is disabled." + else: + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} does not have DKIM signing configured." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ses/ses_service.py b/prowler/providers/aws/services/ses/ses_service.py index ff19f829e6..7d0feb8f54 100644 --- a/prowler/providers/aws/services/ses/ses_service.py +++ b/prowler/providers/aws/services/ses/ses_service.py @@ -46,9 +46,15 @@ class SES(AWSService): identity_attributes = regional_client.get_email_identity( EmailIdentity=identity.name ) - for _, content in identity_attributes["Policies"].items(): + for _, content in identity_attributes.get("Policies", {}).items(): identity.policy = loads(content) - identity.tags = identity_attributes["Tags"] + identity.tags = identity_attributes.get("Tags", []) + dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {} + identity.dkim_status = dkim_attrs.get("Status") + identity.dkim_signing_enabled = dkim_attrs.get("SigningEnabled", False) + identity.dkim_signing_attributes_origin = dkim_attrs.get( + "SigningAttributesOrigin" + ) except Exception as error: logger.error( @@ -67,3 +73,6 @@ class Identity(BaseModel): type: Optional[str] policy: Optional[dict] = None tags: Optional[list] = [] + dkim_status: Optional[str] = None + dkim_signing_attributes_origin: Optional[str] = None + dkim_signing_enabled: Optional[bool] = False diff --git a/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py new file mode 100644 index 0000000000..be4fda3cf4 --- /dev/null +++ b/tests/providers/aws/services/ses/ses_identity_dkim_enabled/ses_identity_dkim_enabled_test.py @@ -0,0 +1,328 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.ses.ses_service import SES +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call_dkim_pass(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pass", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_not_started(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-not-started", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "NOT_STARTED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_fail_failed(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-failed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "FAILED", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_pending(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-pending", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "PENDING", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_dkim_success_not_enabled(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-domain-dkim-verified-not-signed", + } + ], + } + elif operation_name == "GetEmailIdentity": + return { + "Policies": {}, + "Tags": [], + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": False, + "SigningAttributesOrigin": "AWS_SES", + }, + } + return make_api_call(self, operation_name, kwarg) + + +class Test_ses_identity_dkim_enabled: + @mock_aws + def test_no_identities(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 0 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pass, + ) + def test_identity_dkim_enabled_and_verified(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pass has DKIM signing enabled and verified." + ) + assert result[0].resource_id == "test-domain-dkim-pass" + assert ( + result[0].resource_arn + == f"arn:aws:ses:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:identity/test-domain-dkim-pass" + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_not_started, + ) + def test_identity_dkim_not_started(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-not-started has DKIM signing not verified (status: NOT_STARTED)." + ) + assert result[0].resource_id == "test-domain-dkim-not-started" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_fail_failed, + ) + def test_identity_dkim_failed(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-failed has DKIM signing failed verification." + ) + assert result[0].resource_id == "test-domain-dkim-failed" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_pending, + ) + def test_identity_dkim_pending(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-pending has DKIM signing not verified (status: PENDING)." + ) + assert result[0].resource_id == "test-domain-dkim-pending" + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_dkim_success_not_enabled, + ) + def test_identity_dkim_verified_but_not_enabled(self): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_dkim_enabled.ses_identity_dkim_enabled import ( + ses_identity_dkim_enabled, + ) + + check = ses_identity_dkim_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-domain-dkim-verified-not-signed has DKIM verified but signing is disabled." + ) + assert result[0].resource_id == "test-domain-dkim-verified-not-signed" + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/ses/ses_service_test.py b/tests/providers/aws/services/ses/ses_service_test.py index dc1e6eb710..223ed94365 100644 --- a/tests/providers/aws/services/ses/ses_service_test.py +++ b/tests/providers/aws/services/ses/ses_service_test.py @@ -29,6 +29,11 @@ def mock_make_api_call(self, operation_name, kwarg): "policy1": '{"policy1": "value1"}', }, "Tags": {"tag1": "value1", "tag2": "value2"}, + "DkimAttributes": { + "Status": "SUCCESS", + "SigningEnabled": True, + "SigningAttributesOrigin": "AWS_SES", + }, } return make_api_call(self, operation_name, kwarg) @@ -78,3 +83,6 @@ class Test_SES_Service: assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1 assert ses.email_identities[arn].policy == {"policy1": "value1"} assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"} + assert ses.email_identities[arn].dkim_status == "SUCCESS" + assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES" + assert ses.email_identities[arn].dkim_signing_enabled is True