feat(ses): add check for DKIM signing enabled on SES identities (#10923)

Co-authored-by: Mohamed Solaiman <mohamedsolaiman@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
This commit is contained in:
BMO
2026-05-20 13:33:03 +02:00
committed by GitHub
co-authored by Mohamed Solaiman Daniel Barranquero Daniel Barranquero
parent 0ca444895f
commit cff1704d7b
7 changed files with 421 additions and 2 deletions
+1
View File
@@ -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
@@ -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 <IDENTITY-NAME> --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\" \"<example_resource_name>\" {\n domain = \"<example_domain_name>\"\n}\n\n# Add the CNAME records to Route53 (or your DNS provider)\nresource \"aws_route53_record\" \"<example_resource_name>_dkim\" {\n count = 3\n zone_id = \"<route53_zone_id>\"\n name = \"${aws_ses_domain_dkim.<example_resource_name>.dkim_tokens[count.index]}._domainkey.<example_domain_name>\"\n type = \"CNAME\"\n ttl = 600\n records = [\"${aws_ses_domain_dkim.<example_resource_name>.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": ""
}
@@ -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
@@ -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
@@ -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
@@ -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