AWS Data Protection Hardening

Overview

This page covers Amazon Web Services data protection across the storage and key-management surfaces that decide whether plaintext can leak from an AWS estate. Scope is the AWS commercial regions; AWS GovCloud (US) and the China regions inherit the same controls but expose a different key-material partition (KMS keys in aws-us-gov are not usable from commercial accounts and vice versa) — re-verify partition caveats before applying the IaC below outside commercial. CIS sub-IDs and NIST / ISO mappings reference the AWS commercial benchmark unless explicitly annotated as a post-v3.0.0 feature or a best-practice recommendation that the current benchmark has not yet codified. The cross-cutting principles — classification, encryption at rest, key management, and the separation of key custody from data custody — are explained in the General Data Protection page; this page maps them to AWS primitives. Encryption in transit is canonically treated in General Network — encryption in transit and AWS-specifically in AWS Network Hardening; this page does not re-author that material. Severity assignments follow the rubric documented in methodology; equivalence callouts at the bottom of each control point to the matching control on the Azure, GCP, and OCI sibling pages, and the compliance-frameworks page describes why each control row carries the same seven framework columns.

Two anti-conflation callouts up front, because both pairs get muddled in audit reports and IaC reviews and the distinction matters for control design. First: S3 Block Public Access (BPA) has three scopes that are routinely confused. Bucket-level BPA is set per bucket via aws s3api put-public-access-block and protects that bucket only. Account-level BPA is set per account via the entirely different aws s3control put-public-access-block command (note the service: s3control, not s3api) and is a region-independent invariant that overrides any bucket-level setting in the account. Organization-level enforcement is achieved by an SCP that denies s3:PutAccountPublicAccessBlock removal and s3:PutBucketPublicAccessBlock with permissive payloads. Control aws-data-01 targets the account-level invariant pinned by an SCP — the highest leverage of the three. Second: S3 BPA is distinct from VPC Block Public Access (see aws-net-04). Both carry the "Block Public Access" name and both are region-wide invariants, but they protect entirely different resource families — S3 BPA fences bucket ACLs and bucket policies against public grants; VPC BPA fences VPCs against Internet Gateway-mediated traffic. Both must be on. Conflating them in a control catalogue (a real failure mode this site has seen in customer benchmark scorecards) means one of the two ends up disabled by accident.

Order matters. Controls 01–04 are foundational data-at-rest invariants: lock public access to S3 at the account, force default KMS-CMK encryption on every new bucket, enable EBS encryption-by-default region-by-region, and encrypt every RDS instance with a customer-managed key. Controls 05–06 turn to the keys themselves: least-privilege key policies (so that having permission to use data and permission to decrypt data are separate grants) and automatic annual rotation. Control 07 closes the loop with Amazon Macie discovering PII that slipped into otherwise-controlled buckets. KMS keys used here are audited via CloudTrail data events — cross-reference the AWS-internal pointer to AWS Logging Hardening (control aws-log-02 CloudTrail S3 data events) once that page ships in Wave 2. Optional control aws-data-08 (S3 versioning + MFA-delete) is deferred per the 06-RESEARCH open question on regulated-bucket lifecycle rules.

aws-data-01-s3-bpa-account ! CRITICAL PREVENTIVE

Enable Amazon S3 Block Public Access at the account level in every AWS account, with all four sub-settings (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets) set to true, and pin the setting with an organization-wide SCP that denies any principal in any member account from removing it. Account-level BPA is a region-independent invariant managed through the s3control API — distinct from per-bucket BPA which is managed through s3api — and overrides any bucket-level grant that would otherwise expose objects (AWS S3 User Guide — Block Public Access (accessed 2026-05)). It is the single highest-leverage S3 misconfiguration mitigation AWS exposes; CIS AWS Foundations v3.0.0 codifies it as control 2.1.4. The control deliberately uses the SCP pin (not just the account toggle) because every Capital-One-class incident in recent history followed the same template: a single bucket misconfigured by a single engineer in a single account where the platform team had not foreseen this account's emergence.

Remediation — AWS CLI

# Account-level BPA — note the service is s3control (NOT s3api) and the account-id is required.
aws s3control put-public-access-block \
  --account-id 111122223333 \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Verify (note the same service distinction).
aws s3control get-public-access-block --account-id 111122223333

# Bucket-level BPA is a DIFFERENT command (kept for awareness; account-level is the canonical control).
# aws s3api put-public-access-block --bucket my-bucket --public-access-block-configuration ...

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
# Account-level BPA — once-per-account invariant.
resource "aws_s3_account_public_access_block" "this" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Org-wide SCP pinning: deny removal of the account-level invariant from any member account.
resource "aws_organizations_policy" "deny_disable_s3_account_bpa" {
  name = "deny-disable-s3-account-bpa"
  type = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyDisableAccountBpa"
      Effect = "Deny"
      Action = [
        "s3:PutAccountPublicAccessBlock",
        "s3:DeleteAccountPublicAccessBlock"
      ]
      Resource = "*"
      Condition = {
        StringNotEquals = {
          "aws:PrincipalArn" = "arn:aws:iam::${var.platform_account_id}:role/PlatformDataAdmin"
        }
      }
    }]
  })
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Account-level S3 Block Public Access — denies any bucket from ever becoming public.
Resources:
  AccountBpa:
    Type: AWS::S3::AccountPublicAccessBlock
    Properties:
      AccountId: !Ref AWS::AccountId
      BlockPublicAcls: true
      BlockPublicPolicy: true
      IgnorePublicAcls: true
      RestrictPublicBuckets: true

Remediation — AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import { aws_s3 as s3 } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class AccountS3BpaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new s3.CfnAccountPublicAccessBlock(this, 'AccountBpa', {
      accountId: this.account,
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true,
    });
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
2.1.43.x (verify)5.x (verify)4.x (verify) AC-3; AC-6; SC-7A.5.10; A.8.3CLD.9.5.1

Log signals

  • CloudTrail s3:PutAccountPublicAccessBlock events where the request reduces any of the four flags (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets) from true to false — disables the account-level guard so per-bucket public access becomes possible again.
  • CloudTrail s3:DeletePublicAccessBlock at bucket scope — relies on the account-level block being false to actually allow public access, so this event paired with the account-level disable is the full regression chain.
  • CloudTrail s3:PutBucketPolicy where the new policy has a Principal:"*" statement without a Condition on aws:SourceVpce, aws:SourceIp, or aws:PrincipalOrgID — the per-bucket equivalent that takes effect once the BPA guard is off.

Query

fields @timestamp, eventName, requestParameters.publicAccessBlockConfiguration, requestParameters.bucketName, requestParameters.policy, userIdentity.arn
          | filter eventSource = "s3.amazonaws.com" and eventName in ["PutAccountPublicAccessBlock","DeletePublicAccessBlock","PutPublicAccessBlock","PutBucketPolicy"]
          | filter @message like /"BlockPublicAcls":false/ or @message like /"RestrictPublicBuckets":false/ or @message like /"Principal":"\*"/
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query catches all three regression paths in a single sweep; the raw-message regex is necessary because publicAccessBlockConfiguration is a nested JSON object and field-typed filters miss the boolean values inside it on most CloudTrail event variants.

Alert threshold

  • Any PutAccountPublicAccessBlock with any flag set to false — page immediately; the account-level BPA is the fleet-protection backstop and disabling any flag widens every bucket's potential exposure.
  • DeletePublicAccessBlock at bucket scope on any non-data-distribution bucket — high-priority ticket; the bucket should either be tagged public-distribution=true with documented justification or remain locked down.
  • Bucket policy with Principal:"*" and no condition — page; the bucket is now reachable by any anonymous caller and the data-classification tag determines whether the response is a containment incident or a routine misconfiguration ticket.

Initial response

  1. Restore the account-level BPA with aws s3control put-public-access-block --account-id {acct} --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true.
  2. For the per-bucket case, re-apply the canonical bucket policy from IaC and immediately enable aws s3api put-public-access-block at bucket scope as defence-in-depth.
  3. Pull S3 server-access logs and CloudTrail data-event logs for the exposed bucket during the gap window, enumerate every GetObject request from outside the org's VPC-endpoint set, and open an incident via general/ir.html for each accessed object — every such object is a candidate exfiltration target and data-owners must be notified per the bucket's classification tag.

References

Equivalent on: Azure · GCP · OCI

aws-data-02-s3-default-encryption-kms-cmk ! HIGH PREVENTIVE

Set the default encryption on every S3 bucket to aws:kms with a customer-managed CMK — not the AWS-managed aws/s3 key. Since January 2023, AWS encrypts every new S3 object with at least SSE-S3 by default, so the question is no longer "is encryption on" but "whose key is it". The AWS-managed aws/s3 key has a key policy AWS controls; rotating it, revoking it, or applying granular grants to it is impossible from the customer side. A customer-managed CMK (referenced by ARN in the bucket's encryption configuration) puts the customer in control of the key policy, the rotation schedule (covered in aws-data-06), the deletion window, and the audit trail (each Decrypt/GenerateDataKey call is logged in CloudTrail) (AWS S3 User Guide — default bucket encryption (accessed 2026-05)). CIS AWS Foundations v3.0.0 codifies SSE on every bucket as 2.1.1; the CMK requirement is the hardened reading of that control.

Remediation — AWS CLI

# Set default encryption on a bucket to SSE-KMS with a customer-managed CMK.
aws s3api put-bucket-encryption \
  --bucket my-regulated-bucket \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "aws:kms",
        "KMSMasterKeyID": "arn:aws:kms:eu-west-1:111122223333:key/abcd1234-..."
      },
      "BucketKeyEnabled": true
    }]
  }'

# Audit: every bucket missing aws:kms with a CMK ARN.
aws s3api list-buckets --query 'Buckets[].Name' --output text | tr '\t' '\n' | while read b; do
  aws s3api get-bucket-encryption --bucket "$b" 2>/dev/null \
    | grep -q '"SSEAlgorithm": "aws:kms"' || echo "MISSING-CMK $b"
done

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_kms_key" "data_bucket" {
  description             = "CMK for regulated S3 buckets"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

resource "aws_kms_alias" "data_bucket" {
  name          = "alias/data-bucket-cmk"
  target_key_id = aws_kms_key.data_bucket.key_id
}

resource "aws_s3_bucket" "regulated" {
  bucket = "my-regulated-bucket"
}

resource "aws_s3_bucket_server_side_encryption_configuration" "regulated" {
  bucket = aws_s3_bucket.regulated.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.data_bucket.arn
    }
    bucket_key_enabled = true
  }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: S3 bucket using SSE-KMS with a customer-managed CMK and bucket key enabled.
Parameters:
  BucketName:
    Type: String
  KmsKeyArn:
    Type: String
Resources:
  EncryptedBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - BucketKeyEnabled: true
            ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
              KMSMasterKeyID: !Ref KmsKeyArn
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
2.1.13.x (verify)5.x (verify)4.x (verify) SC-13; SC-28A.8.24; A.5.34n/a

Log signals

  • CloudTrail s3:PutBucketEncryption events where requestParameters.serverSideEncryptionConfiguration.rules[0].applyServerSideEncryptionByDefault.sSEAlgorithm shifts from aws:kms to AES256 — drops the CMK-controlled key surface and falls back to S3-managed keys, breaking the key-policy enforcement boundary that downstream IAM relies on.
  • PutBucketEncryption with aws:kms retained but kmsMasterKeyID changed to an alias not in the canonical CMK allow-list — silently re-keys the bucket against a key whose policy may be more permissive.
  • s3:DeleteBucketEncryption events — removes the default-encryption setting entirely; new object uploads still encrypt at rest (S3 enforces this) but with S3-managed keys, the same fallback as AES256.

Query

fields @timestamp, eventName, requestParameters.bucketName, requestParameters.serverSideEncryptionConfiguration, userIdentity.arn
          | filter eventSource = "s3.amazonaws.com" and eventName in ["PutBucketEncryption","DeleteBucketEncryption"]
          | filter eventName = "DeleteBucketEncryption" or @message like /"SSEAlgorithm":"AES256"/ or @message like /"SSEAlgorithm":null/
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query addresses both regression variants; for the CMK-swap case, layer in a second query that joins against the canonical CMK alias list maintained as an SSM Parameter Store value, since the alias-to-key-id resolution can drift over time.

Alert threshold

  • Any PutBucketEncryption shifting to AES256 in production — page immediately; the CMK key-policy layer disappears at that point and the bucket's data is effectively re-keyed to S3-managed at the next write.
  • CMK alias swap to a key outside the allow-list — high-priority ticket within 30 minutes; the destination key's policy may grant decrypt to broader principals and the data's effective access boundary widens.
  • DeleteBucketEncryption — page; same severity as the AES256 shift and indicates an operator believes encryption is not required for the bucket, which is never the org-policy default.

Initial response

  1. Restore the encryption configuration with aws s3api put-bucket-encryption --bucket {name} --server-side-encryption-configuration file://canonical-encryption.json referencing the canonical CMK alias; confirm via get-bucket-encryption read-back.
  2. For objects written during the gap window, identify them via S3 Inventory or by listing objects with a LastModified timestamp inside the window; re-encrypt with the canonical CMK via in-place copy (aws s3 cp s3://{bucket}/{key} s3://{bucket}/{key} --sse aws:kms --sse-kms-key-id {alias}).
  3. Open an incident per general/ir.html if the bucket holds regulated data (PII, PHI, financial); the data-owner must be notified of the encryption-key change because key-rotation audit trails and BYOK attestations cover the canonical CMK but not the S3-managed fallback.

References

Equivalent on: Azure · GCP · OCI

aws-data-03-ebs-encryption-by-default ! HIGH PREVENTIVE

Enable EBS encryption-by-default in every region of every AWS account, and reference a customer-managed CMK as the default EBS key (not the AWS-managed aws/ebs key) so that the key policy, rotation schedule, and audit trail sit with the customer. Once on, every new EBS volume — root and data — is created encrypted with the configured CMK with no additional caller action; every new EBS snapshot is encrypted; every AMI built from an encrypted volume inherits encryption (Amazon EC2 — EBS encryption (accessed 2026-05)). The setting is per-region (not per-account global) — a single API call disables the invariant for that region, so the enable loop must iterate every active region and the configuration must be re-checked when AWS launches a new region. CIS AWS Foundations v3.0.0 codifies the default-encryption requirement as 2.2.1.

Remediation — AWS CLI

# Enable encryption-by-default per region.
for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  aws ec2 enable-ebs-encryption-by-default --region "$region"

  # Point the per-region default to a customer-managed CMK (not aws/ebs).
  aws ec2 modify-ebs-default-kms-key-id \
    --region "$region" \
    --kms-key-id alias/ebs-default-cmk
done

# Verify per region.
aws ec2 get-ebs-encryption-by-default --region eu-west-1
aws ec2 get-ebs-default-kms-key-id    --region eu-west-1

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_kms_key" "ebs_default" {
  description             = "EBS default encryption CMK (per region)"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

resource "aws_kms_alias" "ebs_default" {
  name          = "alias/ebs-default-cmk"
  target_key_id = aws_kms_key.ebs_default.key_id
}

resource "aws_ebs_encryption_by_default" "this" {
  enabled = true
}

resource "aws_ebs_default_kms_key" "this" {
  key_arn = aws_kms_key.ebs_default.arn
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule asserting EBS encryption-by-default is enabled in the region.
Resources:
  EbsEncryptionByDefaultRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: ec2-ebs-encryption-by-default
      Source:
        Owner: AWS
        SourceIdentifier: EC2_EBS_ENCRYPTION_BY_DEFAULT

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
2.2.17.x (verify)4.x (verify)5.x (verify) SC-28; SC-13A.8.24n/a

Log signals

  • CloudTrail ec2:DisableEbsEncryptionByDefault events at region scope — turns off the default-on guard so subsequent CreateVolume calls land an unencrypted volume unless the caller explicitly opts in.
  • CloudTrail ec2:ModifyEbsDefaultKmsKeyId changing the default key to an alias outside the org's data-encryption CMK allow-list — silently re-keys all subsequently-created volumes against a different key whose policy may not be aligned with the data classification.
  • CloudTrail ec2:CreateVolume events where responseElements.volume.encrypted is false — direct evidence of an unencrypted volume creation, useful as backstop signal when the regional default was flipped but the modifier event was missed.

Query

fields @timestamp, eventName, awsRegion, requestParameters.kmsKeyId, responseElements.volume.encrypted, responseElements.volume.volumeId, userIdentity.arn
          | filter eventSource = "ec2.amazonaws.com" and eventName in ["DisableEbsEncryptionByDefault","ModifyEbsDefaultKmsKeyId","CreateVolume"]
          | filter eventName != "CreateVolume" or responseElements.volume.encrypted = false
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query covers all three signals in one pass; for the CreateVolume backstop, layer in a daily inventory job that runs describe-volumes --filters Name=encrypted,Values=false and reports any volume that survived past its expected ephemeral lifetime.

Alert threshold

  • Any DisableEbsEncryptionByDefault in production — page immediately and re-enable within minutes; the regional default is the fleet-wide guard and every subsequent volume creation post-disable lands unencrypted.
  • Default-key change to an alias outside the data-encryption CMK allow-list — high-priority ticket within 30 minutes; the new default key's policy needs validation against the data-encryption posture.
  • CreateVolume with encrypted=false in production — page; the volume must be terminated or migrated to an encrypted snapshot before any sensitive data lands on it.

Initial response

  1. Re-enable EBS encryption-by-default in every affected region with aws ec2 enable-ebs-encryption-by-default --region {region}; restore the canonical default-CMK with modify-ebs-default-kms-key-id.
  2. Inventory unencrypted volumes via aws ec2 describe-volumes --filters Name=encrypted,Values=false Name=tag:env,Values=prod; for each volume, create an encrypted snapshot (aws ec2 create-snapshot with --encrypted), then create a replacement volume from the encrypted snapshot, swap the volume on the EC2 instance during the next maintenance window, and delete the unencrypted source after the swap commits.
  3. Open an incident via general/ir.html if any unencrypted volume holds data classified above public; treat the data as having been at risk during the volume's unencrypted lifetime and notify data-owners per the classification.

References

Equivalent on: Azure · GCP · OCI

aws-data-04-rds-encryption-at-rest ! HIGH PREVENTIVE

Encrypt every Amazon RDS instance and Aurora cluster at rest with a customer-managed CMK; encrypt Performance Insights data with the same CMK family; enable deletion protection. RDS encryption at rest is set at instance creation and cannot be toggled on an existing unencrypted instance — the documented migration is snapshot → copy snapshot with encryption → restore from copy, which requires a maintenance window and capacity for two copies of the database during the migration (Amazon RDS — encryption at rest (accessed 2026-05)). This single property makes "encrypt RDS at creation" a foundational invariant: missing it at provisioning time creates technical debt that is materially expensive to repay. CIS AWS Foundations v3.0.0 codifies the requirement as 2.3.1.

Remediation — AWS CLI

# Audit: every RDS instance without storage encryption.
aws rds describe-db-instances \
  --query 'DBInstances[?StorageEncrypted==`false`].[DBInstanceIdentifier,Engine]' \
  --output table

# New instance: encryption-at-rest with CMK, performance-insights encryption, deletion protection.
aws rds create-db-instance \
  --db-instance-identifier prod-orders \
  --engine postgres --engine-version 16.3 \
  --db-instance-class db.r6i.large \
  --allocated-storage 100 \
  --storage-encrypted \
  --kms-key-id arn:aws:kms:eu-west-1:111122223333:key/abcd1234-... \
  --enable-performance-insights \
  --performance-insights-kms-key-id arn:aws:kms:eu-west-1:111122223333:key/abcd1234-... \
  --deletion-protection \
  --master-username postgres \
  --master-user-password "$(aws secretsmanager get-random-password --output text)"

# Existing unencrypted instance: documented snapshot → copy-with-encryption → restore path.
# (Requires a maintenance window; not in-place.)

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_kms_key" "rds" {
  description             = "RDS encryption-at-rest CMK"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

resource "aws_db_instance" "prod_orders" {
  identifier                            = "prod-orders"
  engine                                = "postgres"
  engine_version                        = "16.3"
  instance_class                        = "db.r6i.large"
  allocated_storage                     = 100
  storage_encrypted                     = true
  kms_key_id                            = aws_kms_key.rds.arn
  performance_insights_enabled          = true
  performance_insights_kms_key_id       = aws_kms_key.rds.arn
  performance_insights_retention_period = 731
  deletion_protection                   = true
  backup_retention_period               = 30
  username                              = "postgres"
  manage_master_user_password           = true
  master_user_secret_kms_key_id         = aws_kms_key.rds.arn
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule asserting all RDS DB instances are encrypted at rest.
Resources:
  RdsStorageEncryptedRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: rds-storage-encrypted
      Source:
        Owner: AWS
        SourceIdentifier: RDS_STORAGE_ENCRYPTED
      Scope:
        ComplianceResourceTypes:
          - AWS::RDS::DBInstance

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
2.3.14.x (verify)6.x (verify)5.x (verify) SC-28; SC-13A.8.24n/a

Log signals

  • CloudTrail rds:CreateDBInstance or rds:CreateDBCluster events where requestParameters.storageEncrypted is false — RDS does not allow toggling encryption after creation, so the at-launch signal is the only practical detection point and there is no in-flight remediation other than snapshot-restore.
  • CloudTrail rds:ModifyDBInstance where requestParameters.publiclyAccessible flips to true on a previously-private instance — adjacent posture regression that frequently accompanies the encryption-at-rest gap and matters for the data-exposure response.
  • CloudTrail rds:RestoreDBInstanceFromDBSnapshot where the source snapshot was unencrypted and the restore creates a new unencrypted instance — alternative path to unencrypted RDS that bypasses the CreateDBInstance filter.

Query

fields @timestamp, eventName, requestParameters.dBInstanceIdentifier, requestParameters.storageEncrypted, requestParameters.publiclyAccessible, requestParameters.kmsKeyId, userIdentity.arn
          | filter eventSource = "rds.amazonaws.com" and eventName in ["CreateDBInstance","CreateDBCluster","RestoreDBInstanceFromDBSnapshot","ModifyDBInstance"]
          | filter requestParameters.storageEncrypted = false or requestParameters.publiclyAccessible = true
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query catches both regression paths; for the snapshot-restore case, layer in a second query on rds:CopyDBSnapshot events where the copy is created without kmsKeyId (the copy is the only step that can introduce encryption to a previously-unencrypted snapshot).

Alert threshold

  • Any CreateDBInstance or CreateDBCluster with storageEncrypted=false in production — page immediately; the instance cannot be encrypted after creation and the only path is snapshot-copy-encrypted + restore, which means downtime to fix.
  • ModifyDBInstance with publiclyAccessible=true on any RDS instance holding data classified above public — page; the data is now Internet-reachable subject only to the security-group and master-credential gate.
  • RestoreDBInstanceFromDBSnapshot from an unencrypted snapshot — high-priority ticket; the restore creates a new unencrypted instance and the operator should have used a copy-encrypted intermediate snapshot.

Initial response

  1. For unencrypted production instances: create an encrypted snapshot via aws rds copy-db-snapshot --kms-key-id {alias}, restore the encrypted snapshot to a new instance, swap the application's connection string in a maintenance window, then delete the unencrypted source after verifying the encrypted replacement is fully caught up.
  2. For publicly-accessible RDS, immediately modify with aws rds modify-db-instance --no-publicly-accessible --apply-immediately; the change is non-disruptive but DNS propagation can take minutes during which the previous public endpoint may still resolve.
  3. Open an incident via general/ir.html; pull VPC Flow Logs for the RDS ENI during the public-access window and enumerate every inbound flow from outside the corporate egress CIDRs — each is a candidate adversary connection that may have authenticated against the master credentials.

References

Equivalent on: Azure · GCP · OCI

aws-data-05-kms-key-policy-least-priv ! CRITICAL PREVENTIVE

Author each customer-managed KMS key policy to grant kms:Decrypt and kms:GenerateDataKey only to specific role ARNs that have a documented need, retain the root-account grant for break-glass administration (do not remove it — without it the key becomes unmanageable if the only admin role is lost), and set a deletion window of at least 30 days. The key policy is the single authority on who can use a CMK — IAM permissions are not sufficient on their own; both layers must agree (AWS KMS Developer Guide — key policies (accessed 2026-05)). The most common antipattern (audited against the Phase 5 IAM rubric of least privilege) is the open Principal: "*" with a kms:ViaService condition, which permits decrypt by any principal that can route a call via the named service. CRITICAL because a permissive key policy nullifies every other data-at-rest control on this page: encryption with a key that anyone can decrypt is theatre.

Remediation — AWS CLI

# Inspect existing key policy.
aws kms get-key-policy --key-id alias/data-bucket-cmk --policy-name default \
  | jq -r .Policy | jq .

# Replace with a least-privilege policy.
cat > /tmp/key-policy.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RootAccountBreakGlass",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111122223333:root"},
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AppRoleEncryptDecrypt",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111122223333:role/app-prod"},
      "Action": ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
      "Resource": "*"
    }
  ]
}
JSON

aws kms put-key-policy \
  --key-id alias/data-bucket-cmk \
  --policy-name default \
  --policy file:///tmp/key-policy.json

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
data "aws_caller_identity" "current" {}

resource "aws_kms_key" "data_bucket" {
  description             = "CMK for regulated S3 buckets (least-privilege policy)"
  deletion_window_in_days = 30
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "RootAccountBreakGlass"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
        Action    = "kms:*"
        Resource  = "*"
      },
      {
        Sid    = "AppRoleEncryptDecrypt"
        Effect = "Allow"
        Principal = {
          AWS = [
            "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/app-prod"
          ]
        }
        Action = [
          "kms:Decrypt",
          "kms:GenerateDataKey",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: KMS CMK with a least-privilege key policy — admin and usage principals separated, no Principal '*'.
Parameters:
  AdminRoleArn:
    Type: String
  UsageRoleArn:
    Type: String
Resources:
  AppCmk:
    Type: AWS::KMS::Key
    Properties:
      Description: Application-scoped CMK with admin/usage separation.
      EnableKeyRotation: true
      KeyPolicy:
        Version: '2012-10-17'
        Statement:
          - Sid: EnableAccountRoot
            Effect: Allow
            Principal:
              AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
            Action: kms:*
            Resource: '*'
          - Sid: AdminPlane
            Effect: Allow
            Principal:
              AWS: !Ref AdminRoleArn
            Action:
              - kms:Create*
              - kms:Describe*
              - kms:Enable*
              - kms:List*
              - kms:Put*
              - kms:Update*
              - kms:Revoke*
              - kms:Disable*
              - kms:Get*
              - kms:Delete*
              - kms:TagResource
              - kms:UntagResource
              - kms:ScheduleKeyDeletion
              - kms:CancelKeyDeletion
            Resource: '*'
          - Sid: UsagePlane
            Effect: Allow
            Principal:
              AWS: !Ref UsageRoleArn
            Action:
              - kms:Encrypt
              - kms:Decrypt
              - kms:ReEncrypt*
              - kms:GenerateDataKey*
              - kms:DescribeKey
            Resource: '*'

Remediation — AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import { aws_kms as kms, aws_iam as iam } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface AppCmkProps extends cdk.StackProps {
  adminRoleArn: string;
  usageRoleArn: string;
}

export class AppCmkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: AppCmkProps) {
    super(scope, id, props);

    const adminRole = iam.Role.fromRoleArn(this, 'AdminRole', props.adminRoleArn);
    const usageRole = iam.Role.fromRoleArn(this, 'UsageRole', props.usageRoleArn);

    const key = new kms.Key(this, 'AppCmk', {
      description: 'Application-scoped CMK with admin/usage separation.',
      enableKeyRotation: true,
    });

    key.addToResourcePolicy(new iam.PolicyStatement({
      sid: 'AdminPlane',
      principals: [adminRole],
      actions: [
        'kms:Create*', 'kms:Describe*', 'kms:Enable*', 'kms:List*', 'kms:Put*',
        'kms:Update*', 'kms:Revoke*', 'kms:Disable*', 'kms:Get*', 'kms:Delete*',
        'kms:TagResource', 'kms:UntagResource',
        'kms:ScheduleKeyDeletion', 'kms:CancelKeyDeletion',
      ],
      resources: ['*'],
    }));

    key.addToResourcePolicy(new iam.PolicyStatement({
      sid: 'UsagePlane',
      principals: [usageRole],
      actions: [
        'kms:Encrypt', 'kms:Decrypt', 'kms:ReEncrypt*',
        'kms:GenerateDataKey*', 'kms:DescribeKey',
      ],
      resources: ['*'],
    }));
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a AC-6; SC-12A.5.15; A.8.24n/a

Log signals

  • CloudTrail kms:PutKeyPolicy events on production CMKs where the new policy contains "Principal":"*" without a Condition on kms:CallerAccount, aws:PrincipalOrgID, or aws:SourceVpce — opens the key's Decrypt / GenerateDataKey surface to any principal in the universe that has IAM permission to call the operation.
  • CloudTrail kms:CreateGrant events where the grantee is an external account or a service-role outside the documented per-key grantee allow-list — grants are a sideband authorization channel that bypasses the key policy and frequently used for legitimate cross-account access but every new grant warrants review.
  • CloudTrail kms:ScheduleKeyDeletion with pendingWindowInDays below 30 — the 30-day floor is the AWS-recommended grace window and shorter values are a deliberate compress-the-blast-radius signal that pairs with attempts to destroy forensic key material.

Query

fields @timestamp, eventName, requestParameters.keyId, requestParameters.policy, requestParameters.granteePrincipal, requestParameters.pendingWindowInDays, userIdentity.arn
          | filter eventSource = "kms.amazonaws.com" and eventName in ["PutKeyPolicy","CreateGrant","ScheduleKeyDeletion","UpdateAlias"]
          | filter @message like /"Principal":"\*"/ or eventName != "PutKeyPolicy"
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query catches the policy-widening idiom directly and surfaces grant / delete signals adjacently; the "Principal":"*" substring is the highest-signal positive because the org's authored key policies never use it without a tight condition.

Alert threshold

  • Any PutKeyPolicy introducing Principal:"*" without an organisation-condition — page immediately; the key's effective access surface widened to the entire AWS public surface at the moment of the change.
  • A grant to an external account ID outside the cross-account allow-list — page; the grant is a side-channel that the key policy does not gate and any unexpected external grantee is high-confidence credential abuse.
  • ScheduleKeyDeletion with pendingWindowInDays < 30 on a production CMK — page and immediately call cancel-key-deletion; the short window is the canonical sabotage indicator.

Initial response

  1. Restore the key policy from IaC with aws kms put-key-policy --key-id {alias} --policy file://canonical-policy.json; for grants, revoke with aws kms revoke-grant referencing the grant-id from the CloudTrail event.
  2. Inventory kms:Decrypt and kms:GenerateDataKey calls against the key during the permissive window via CloudTrail; any caller outside the documented allow-list is a candidate data-exposure event and the corresponding ciphertext should be considered potentially exfiltrated.
  3. Open an incident via general/ir.html if any external principal called kms:Decrypt during the window; the data encrypted under the key needs to be considered as potentially read by that principal, and a key-rotation event (aws-data-06-kms-rotation) should be triggered to invalidate forward use of the now-suspect key material.

References

Equivalent on: Azure · GCP · OCI

aws-data-06-kms-rotation ! MEDIUM DETECTIVE

Enable annual automatic rotation on every customer-managed symmetric CMK in the account, and assert the property via AWS Config managed rule cmk-backing-key-rotation-enabled. Automatic rotation re-keys the underlying backing-key material on a 365-day schedule (custom periods 90–2560 days available since 2023); the key ARN and key ID do not change, so consumers do not need to be re-pointed, but the cryptographic material protecting new ciphertext is fresh (AWS KMS Developer Guide — rotating keys (accessed 2026-05)). The typology is DETECTIVE, not PREVENTIVE — following the rubric in methodology, rotation does not prevent a key compromise (an attacker who has stolen the current backing key still has it); it bounds the usable window of a compromised key, and surfaces stale-key configurations through the Config rule. This is the same PITFALL B-14 distinction made for IAM access-key rotation (aws-iam-05) on the Phase 5 page. Severity MEDIUM because rotation alone is a weak mitigation against a determined attacker; it shines when paired with the key-policy hygiene of aws-data-05 and the audit trail of CloudTrail KMS events.

Remediation — AWS CLI

# Enable rotation on an existing CMK.
aws kms enable-key-rotation --key-id alias/data-bucket-cmk

# Verify.
aws kms get-key-rotation-status --key-id alias/data-bucket-cmk \
  --query 'KeyRotationEnabled' --output text

# Detective: AWS Config managed rule across the estate.
aws configservice put-config-rule --config-rule '{
  "ConfigRuleName":"cmk-backing-key-rotation-enabled",
  "Source":{"Owner":"AWS","SourceIdentifier":"CMK_BACKING_KEY_ROTATION_ENABLED"}}'

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_kms_key" "data_bucket" {
  description             = "CMK for regulated S3 buckets"
  deletion_window_in_days = 30
  enable_key_rotation     = true
  rotation_period_in_days = 365
}

# Detective complement: Config rule across the account.
resource "aws_config_config_rule" "cmk_rotation" {
  name = "cmk-backing-key-rotation-enabled"
  source {
    owner             = "AWS"
    source_identifier = "CMK_BACKING_KEY_ROTATION_ENABLED"
  }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule asserting automatic key rotation is enabled on all customer-managed CMKs.
Resources:
  KmsCmkRotationRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: cmk-backing-key-rotation-enabled
      Source:
        Owner: AWS
        SourceIdentifier: CMK_BACKING_KEY_ROTATION_ENABLED

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
2.1.x (verify)n/an/an/a SC-12; SC-12(1)A.8.24n/a

Log signals

  • CloudTrail kms:DisableKeyRotation events on production CMKs — stops the AWS-managed annual automatic rotation; future key material remains at the current epoch and CVE / cryptographic-best-practice rotations stop.
  • CloudTrail kms:GetKeyRotationStatus responses where keyRotationEnabled is false on a key whose alias indicates production usage — passive signal during the daily completeness sweep that finds keys whose rotation has been off for some time.
  • For customer-managed rotation (BYOK): absence of kms:ImportKeyMaterial events for more than the org's rotation cadence (typically 365 days) on a CMK with imported key material — rotation hasn't happened and the imported key material is past its policy-defined lifetime.

Query

fields @timestamp, eventName, requestParameters.keyId, userIdentity.arn, sourceIPAddress
          | filter eventSource = "kms.amazonaws.com" and eventName in ["DisableKeyRotation","EnableKeyRotation","ImportKeyMaterial","DeleteImportedKeyMaterial"]
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query captures the active mutation events; pair with a daily Lambda that walks list-keys and calls get-key-rotation-status on each, emitting a CloudWatch metric for the count of keys with rotation disabled. The metric drives a threshold alarm at zero so any non-zero count fires immediately.

Alert threshold

  • Any DisableKeyRotation on a production CMK — page immediately; the rotation cadence is a compliance control and disabling it is a deliberate posture downgrade that breaks SOC 2 / PCI / HIPAA attestations.
  • Imported-key-material CMK without ImportKeyMaterial in the rotation window — high-priority ticket; the key material is past its policy lifetime and the BYOK rotation playbook needs to execute.
  • Daily-sweep count of production CMKs with rotation disabled — informational at first occurrence, page if the count stays non-zero for two consecutive days.

Initial response

  1. Re-enable rotation with aws kms enable-key-rotation --key-id {alias}; for BYOK keys, schedule the next import via the BYOK ceremony playbook.
  2. Trigger a one-shot rotation by waiting up to 24 hours for the AWS-managed rotation to fire, or for BYOK by importing fresh key material with a new ExpirationModel shorter than the policy lifetime; document the rotation completion in the key-management ledger.
  3. Open an incident via general/ir.html if the rotation was disabled for more than the org's compliance cadence; the data encrypted under the long-running key material is potentially in scope for a re-encryption cycle, particularly for regulated workloads where the cryptoperiod is bounded by policy.

References

Equivalent on: Azure · GCP · OCI

aws-data-07-macie-pii-scan ! MEDIUM DETECTIVE

Enable Amazon Macie at the AWS Organization level with a delegated administrator account, run sensitive-data discovery jobs on buckets in scope for PII/PCI/PHI classifications, and route findings to AWS Security Hub for triage and ticketing (Amazon Macie User Guide (accessed 2026-05)). Macie's value proposition is bounded: it discovers known sensitive-data patterns (US/EU PII, credit-card numbers, AWS credential material, common health-record markers) in S3 — it is not a general DLP, it does not block writes, and it does not see across services other than S3. As a detective complement to the preventive controls in aws-data-01..04, Macie closes the loop on accidental writes of PII into buckets the platform team did not designate as sensitive. Severity MEDIUM because Macie alone does not change the security posture; it changes the visibility into the posture, and visibility is what makes the preventive controls auditable.

Remediation — AWS CLI

# Org-level: delegate Macie admin to the security account.
aws macie2 enable-organization-admin-account \
  --admin-account-id 222233334444

# In the delegated admin: enable Macie and schedule a sensitive-data discovery job.
aws macie2 enable-macie --status ENABLED --finding-publishing-frequency FIFTEEN_MINUTES

aws macie2 create-classification-job \
  --job-type SCHEDULED \
  --schedule-frequency '{"DailySchedule":{}}' \
  --name "pii-discovery-regulated-buckets" \
  --s3-job-definition '{"bucketDefinitions":[
      {"accountId":"111122223333","buckets":["my-regulated-bucket"]}
    ]}'

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_macie2_account" "this" {
  finding_publishing_frequency = "FIFTEEN_MINUTES"
  status                       = "ENABLED"
}

resource "aws_macie2_organization_admin_account" "this" {
  admin_account_id = var.security_account_id
  depends_on       = [aws_macie2_account.this]
}

resource "aws_macie2_classification_job" "regulated" {
  job_type = "SCHEDULED"
  name     = "pii-discovery-regulated-buckets"

  s3_job_definition {
    bucket_definitions {
      account_id = data.aws_caller_identity.current.account_id
      buckets    = ["my-regulated-bucket"]
    }
  }

  schedule_frequency {
    daily_schedule = true
  }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Enable Amazon Macie in the account with continuous classification on a one-hour interval.
Resources:
  MacieSession:
    Type: AWS::Macie::Session
    Properties:
      FindingPublishingFrequency: ONE_HOUR
      Status: ENABLED

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a RA-5; SI-4A.5.12; A.5.13CLD.12.4.5

Log signals

  • CloudTrail macie2:DisableMacie events in any member account or the delegated-administrator — stops the sensitive-data discovery pipeline tenancy-wide if executed on the admin account.
  • CloudTrail macie2:DeleteClassificationJob or macie2:UpdateClassificationJob with jobStatus=CANCELLED — terminates an in-progress sensitive-data sweep before it completes, leaving the target bucket population unscanned.
  • Macie finding-export stream volume drops to zero on a 7-day-baseline basis while the bucket population has new objects according to S3 Inventory — passive signal that scans are not producing findings even when the job configuration looks correct.

Query

fields @timestamp, eventName, requestParameters.jobId, requestParameters.jobStatus, requestParameters.accountIds, userIdentity.arn
          | filter eventSource = "macie2.amazonaws.com" and eventName in ["DisableMacie","DeleteClassificationJob","UpdateClassificationJob","DisassociateMember","DeleteMember"]
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query covers the admin-side disable paths; the finding-volume signal is best served by a CloudWatch metric on the Macie findingsPublished metric with an absence-of-signal threshold at 10% of the trailing-7-day rolling mean.

Alert threshold

  • Any DisableMacie on the delegated-administrator account — page immediately; the org's sensitive-data discovery posture goes dark and findings stop fanning out.
  • DeleteClassificationJob on a job covering a production bucket — high-priority ticket within 30 minutes; the bucket's sensitive-data inventory is now stale and the operator must either re-run the job or document why coverage is no longer required.
  • Findings-published rate below 10% of 7-day baseline for two consecutive 1-hour windows — informational; promote to incident if the divergence persists for 6 hours, since the typical Macie cadence produces findings throughout the day across an active bucket population.

Initial response

  1. Re-enable Macie at the delegated-administrator account with aws macie2 enable-macie --finding-publishing-frequency FIFTEEN_MINUTES and re-associate any disassociated member accounts via create-member + invite flow.
  2. For terminated classification jobs, re-create with aws macie2 create-classification-job using the canonical job-template from IaC; trigger a one-shot sensitive-data scan against the affected buckets so the inventory closes the gap.
  3. Open an incident via general/ir.html if any bucket contained newly-uploaded objects during the scan-gap window; the data may have been written or read without Macie-driven controls firing, and the data-owner must be notified per the bucket's classification tag for a manual sensitive-data review.

References

Equivalent on: Azure · GCP · OCI

Sources