diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_accelerator_cluster_encryption_enabled/dynamodb_accelerator_cluster_encryption_enabled.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_accelerator_cluster_encryption_enabled/dynamodb_accelerator_cluster_encryption_enabled.metadata.json index bcc04b54e0..edd28d3a37 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_accelerator_cluster_encryption_enabled/dynamodb_accelerator_cluster_encryption_enabled.metadata.json +++ b/prowler/providers/aws/services/dynamodb/dynamodb_accelerator_cluster_encryption_enabled/dynamodb_accelerator_cluster_encryption_enabled.metadata.json @@ -7,7 +7,7 @@ ], "ServiceName": "dynamodb", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:dynamodb:region:account-id:certificate/resource-id", + "ResourceIdTemplate": "arn:partition:dax:region:account-id:cache/resource-id", "Severity": "medium", "ResourceType": "AwsDaxCluster", "Description": "Check if DynamoDB DAX Clusters are encrypted at rest.", diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_service.py b/prowler/providers/aws/services/dynamodb/dynamodb_service.py index 626a916c61..d4dcf5e2df 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_service.py +++ b/prowler/providers/aws/services/dynamodb/dynamodb_service.py @@ -1,3 +1,4 @@ +import json from typing import Optional from botocore.client import ClientError @@ -17,6 +18,7 @@ class DynamoDB(AWSService): self.__threading_call__(self.__list_tables__) self.__describe_table__() self.__describe_continuous_backups__() + self.__get_resource_policy__() self.__list_tags_for_resource__() def __list_tables__(self, regional_client): @@ -93,6 +95,35 @@ class DynamoDB(AWSService): f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) + def __get_resource_policy__(self): + logger.info("DynamoDB - Get Resource Policy...") + try: + for table in self.tables: + try: + regional_client = self.regional_clients[table.region] + response = regional_client.get_resource_policy( + ResourceArn=table.arn + ) + table.policy = json.loads(response["Policy"]) + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + elif error.response["Error"]["Code"] == "PolicyNotFoundException": + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def __list_tags_for_resource__(self): logger.info("DynamoDB - List Tags...") try: @@ -186,6 +217,7 @@ class Table(BaseModel): encryption_type: Optional[str] kms_arn: Optional[str] pitr: bool = False + policy: Optional[dict] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/__init__.py b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json new file mode 100644 index 0000000000..53941c4e32 --- /dev/null +++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "aws", + "CheckID": "dynamodb_table_cross_account_access", + "CheckTitle": "DynamoDB tables should not be accessible from other AWS accounts", + "CheckType": [ + "Infrastructure Security" + ], + "ServiceName": "dynamodb", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:dynamodb:region:account-id:table/resource-id", + "Severity": "medium", + "ResourceType": "AwsDynamoDBTable", + "Description": "This check determines if the DynamoDB table is accessible from other AWS accounts.", + "Risk": "If the DynamoDB table is accessible from other AWS accounts, it may lead to unauthorized access to the data stored in the table.", + "RelatedUrl": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/access-control-resource-based.html", + "Remediation": { + "Code": { + "CLI": "aws dynamodb delete-resource-policy --resource-arn ", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure that the DynamoDB table is not accessible from other AWS accounts.", + "Url": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/rbac-bpa-rbp.html" + } + }, + "Categories": [ + "trustboundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py new file mode 100644 index 0000000000..e7492fb221 --- /dev/null +++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.lib.policy_condition_parser.policy_condition_parser import ( + is_condition_block_restrictive, +) +from prowler.providers.aws.services.dynamodb.dynamodb_client import dynamodb_client + + +class dynamodb_table_cross_account_access(Check): + def execute(self): + findings = [] + for table in dynamodb_client.tables: + report = Check_Report_AWS(self.metadata()) + report.resource_id = table.name + report.resource_arn = table.arn + report.resource_tags = table.tags + report.region = table.region + report.status = "PASS" + report.status_extended = ( + f"DynamoDB table {table.name} does not have a resource-based policy." + ) + if table.policy: + report.status_extended = f"DynamoDB table {table.name} has a resource-based policy but is not cross account." + cross_account_access = False + policy_statements = table.policy["Statement"] + if isinstance( + policy_statements, dict + ): # Normalize single statement to list + policy_statements = [policy_statements] + for statement in policy_statements: + if not cross_account_access: + if statement["Effect"] == "Allow": + if "AWS" in statement["Principal"]: + principals = statement["Principal"]["AWS"] + if not isinstance(principals, list): + principals = [principals] + else: + principals = [statement["Principal"]] + for aws_account in principals: + if ( + dynamodb_client.audited_account not in aws_account + or "*" == aws_account + ): + cross_account_access = True + # Check if the condition block is restrictive + conditions = statement.get("Condition", {}) + if is_condition_block_restrictive( + conditions, dynamodb_client.audited_account + ): + cross_account_access = False + else: + break + if cross_account_access: + report.status = "FAIL" + report.status_extended = f"DynamoDB table {table.name} has a resource-based policy allowing cross account access." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_tables_pitr_enabled/dynamodb_tables_pitr_enabled.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_tables_pitr_enabled/dynamodb_tables_pitr_enabled.metadata.json index 2304877659..1c8fadbadd 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_tables_pitr_enabled/dynamodb_tables_pitr_enabled.metadata.json +++ b/prowler/providers/aws/services/dynamodb/dynamodb_tables_pitr_enabled/dynamodb_tables_pitr_enabled.metadata.json @@ -7,7 +7,7 @@ ], "ServiceName": "dynamodb", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:dynamodb:region:account-id:certificate/resource-id", + "ResourceIdTemplate": "arn:partition:dynamodb:region:account-id:table/resource-id", "Severity": "medium", "ResourceType": "AwsDynamoDBTable", "Description": "Check if DynamoDB tables point-in-time recovery (PITR) is enabled.", diff --git a/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py b/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py new file mode 100644 index 0000000000..55849a8cf4 --- /dev/null +++ b/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py @@ -0,0 +1,381 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 + +test_table_name = str(uuid4()) +test_table_arn = f"arn:aws:dynamodb:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:table/{test_table_name}" + +test_restricted_policy = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": {"AWS": {AWS_ACCOUNT_NUMBER}}, + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + } + ], +} + +test_public_policy = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": "*", + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + } + ], +} + +test_public_policy_with_condition_same_account_not_valid = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": "*", + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + "Condition": { + "DateGreaterThan": {"aws:CurrentTime": "2009-01-31T12:00Z"}, + "DateLessThan": {"aws:CurrentTime": "2009-01-31T15:00Z"}, + }, + } + ], +} + +test_public_policy_with_condition_same_account = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": "*", + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + "Condition": { + "StringEquals": {"aws:SourceAccount": f"{AWS_ACCOUNT_NUMBER}"} + }, + } + ], +} + +test_public_policy_with_condition_diff_account = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": "*", + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + "Condition": {"StringEquals": {"aws:SourceAccount": "111122223333"}}, + } + ], +} + +test_public_policy_with_invalid_condition_block = { + "Version": "2012-10-17", + "Id": "Table1_Policy_UUID", + "Statement": [ + { + "Sid": "Table1_AnonymousAccess_GetItem", + "Effect": "Allow", + "Principal": "*", + "Action": "dynamodb:BatchGetItem", + "Resource": test_table_arn, + "Condition": {"DateGreaterThan": {"aws:CurrentTime": "2009-01-31T12:00Z"}}, + } + ], +} + + +class Test_dynamodb_table_cross_account_access: + def test_no_tables(self): + dynamodb_client = mock.MagicMock + dynamodb_client.tables = [] + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 0 + + def test_tables_no_policy(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables = [] + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"DynamoDB table {test_table_name} does not have a resource-based policy." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_not_public(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables = [] + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_restricted_policy, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"DynamoDB table {test_table_name} has a resource-based policy but is not cross account." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_public(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables = [] + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_public_policy, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DynamoDB table {test_table_name} has a resource-based policy allowing cross account access." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_public_with_condition_not_valid(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.tables = [] + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_public_policy_with_condition_same_account_not_valid, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DynamoDB table {test_table_name} has a resource-based policy allowing cross account access." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_public_with_condition_valid(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.tables = [] + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_public_policy_with_condition_same_account, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"DynamoDB table {test_table_name} has a resource-based policy but is not cross account." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_public_with_condition_valid_with_other_account(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.tables = [] + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_public_policy_with_condition_diff_account, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"DynamoDB table {test_table_name} has a resource-based policy allowing cross account access." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_tables_public_with_condition_with_invalid_block(self): + dynamodb_client = mock.MagicMock + from prowler.providers.aws.services.dynamodb.dynamodb_service import Table + + dynamodb_client.tables = [] + dynamodb_client.audited_account = AWS_ACCOUNT_NUMBER + dynamodb_client.tables.append( + Table( + arn=test_table_arn, + name=test_table_name, + region=AWS_REGION_EU_WEST_1, + policy=test_public_policy_with_invalid_condition_block, + ) + ) + with mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", + new=dynamodb_client, + ), mock.patch( + "prowler.providers.aws.services.dynamodb.dynamodb_client.dynamodb_client", + new=dynamodb_client, + ): + from prowler.providers.aws.services.dynamodb.dynamodb_table_cross_account_access.dynamodb_table_cross_account_access import ( + dynamodb_table_cross_account_access, + ) + + check = dynamodb_table_cross_account_access() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DynamoDB table {test_table_name} has a resource-based policy allowing cross account access." + ) + assert result[0].resource_id == test_table_name + assert result[0].resource_arn == test_table_arn + assert result[0].resource_tags == [] + assert result[0].region == AWS_REGION_EU_WEST_1