Heawood

A free guide by Heawood. .

The AWS settings that leak data: 12 checks you can run in 10 minutes

Most data that leaks from AWS doesn't leave through a clever exploit. It leaves through a setting someone changed for a good reason and never changed back. These are twelve of the most common. For each one there's the AWS CLI command to find it in your own account, how to read the answer, and the command to fix it.

You need the AWS CLI v2 and read access to the account. Commands that list resources look at your default region; add --region to check the others.

1.Database and admin ports open to the internet

A security group rule from 0.0.0.0/0 to a database, SSH or remote desktop port puts that service one password, or one unpatched bug, away from anyone on the internet. They're usually opened for a quick test and never closed.

Check
aws ec2 describe-security-groups \
  --filters Name=ip-permission.cidr,Values=0.0.0.0/0 \
  --query "SecurityGroups[].{group:GroupId,name:GroupName,open:IpPermissions[?contains(IpRanges[].CidrIp,'0.0.0.0/0')].[IpProtocol,FromPort,ToPort]}"

Look for ports 22, 3389, 3306, 5432, 6379, 9200 and 27017, and for protocol -1, which means every port. Run it in each region you use (add --region), and again with Name=ip-permission.ipv6-cidr,Values=::/0 for IPv6.

Fix
aws ec2 revoke-security-group-ingress --group-id sg-0123456789abcdef0 \
  --protocol tcp --port 5432 --cidr 0.0.0.0/0

Then reach the service privately: from inside the VPC, through Session Manager, or over a VPN.

2.Servers that can send data anywhere

Every security group starts with a rule that allows all outbound traffic. On a server that holds credentials, a compromised dependency can send your keys and data straight to the attacker, and nothing stops it on the way out.

Check
aws ec2 describe-security-groups \
  --query "SecurityGroups[?IpPermissionsEgress[?IpProtocol=='-1' && contains(IpRanges[].CidrIp,'0.0.0.0/0')]].[GroupId,GroupName]" \
  --output table

aws ec2 describe-instances --filters Name=instance.group-id,Values=sg-0123456789abcdef0 \
  --query "Reservations[].Instances[?IamInstanceProfile][].[InstanceId,IamInstanceProfile.Arn]" \
  --output table

The first command lists nearly every group, since allow-all is the default. The second shows which servers in one of those groups run with a role: those are the ones to fix first.

Fix
aws ec2 authorize-security-group-egress --group-id sg-0123456789abcdef0 \
  --ip-permissions 'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=203.0.113.0/24}]'

aws ec2 revoke-security-group-egress --group-id sg-0123456789abcdef0 \
  --ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]'

Add what the server needs first, then remove allow-all. Reach AWS services through VPC endpoints so they don't need internet access at all.

3.S3 Block Public Access switched off for the account

With the account-level setting off, one bucket policy or ACL change is all it takes to make a bucket public. With it on, S3 refuses public access everywhere, even if someone tries.

Check
aws s3control get-public-access-block \
  --account-id "$(aws sts get-caller-identity --query Account --output text)"

All four settings should be true. An error saying NoSuchPublicAccessBlockConfiguration means it was never turned on.

Fix
aws s3control put-public-access-block \
  --account-id "$(aws sts get-caller-identity --query Account --output text)" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

If a bucket serves a public website, put CloudFront in front of it with origin access control first; the bucket itself doesn't need to be public.

4.Buckets anyone can read or write

Public buckets are still the most common way data leaks from AWS: backups, exports and logs that were shared “just for a minute”.

Check
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
  policy=$(aws s3api get-bucket-policy-status --bucket "$b" \
    --query PolicyStatus.IsPublic --output text 2>/dev/null || echo none)
  acl=$(aws s3api get-bucket-acl --bucket "$b" \
    --query "length(Grants[?Grantee.URI=='http://acs.amazonaws.com/groups/global/AllUsers'])")
  echo "$b public-by-policy=$policy public-by-acl=$acl"
done

public-by-policy=True, or public-by-acl above 0, means anyone on the internet can reach the bucket.

Fix
aws s3api put-public-access-block --bucket my-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

5.Databases with a public address

A database marked publicly accessible gets an address on the internet. If its security group is open too, the only thing protecting it is the password.

Check
aws rds describe-db-instances \
  --query "DBInstances[?PubliclyAccessible].[DBInstanceIdentifier,Engine,Endpoint.Address]" \
  --output table

Every row is a database with a public address. Run it in each region you use.

Fix
aws rds modify-db-instance --db-instance-identifier my-db \
  --no-publicly-accessible --apply-immediately

6.Snapshots shared with every AWS account

A public disk or database snapshot can be copied by anyone with an AWS account, along with every file or row in it. It's one checkbox away when sharing a snapshot with a partner.

Check
for s in $(aws ec2 describe-snapshots --owner-ids self \
  --query 'Snapshots[].SnapshotId' --output text); do
  echo "$s: $(aws ec2 describe-snapshot-attribute --snapshot-id "$s" \
    --attribute createVolumePermission --query 'CreateVolumePermissions[].Group' --output text)"
done

for s in $(aws rds describe-db-snapshots --snapshot-type manual \
  --query 'DBSnapshots[].DBSnapshotIdentifier' --output text); do
  echo "$s: $(aws rds describe-db-snapshot-attributes --db-snapshot-identifier "$s" \
    --query "DBSnapshotAttributesResult.DBSnapshotAttributes[?AttributeName=='restore'].AttributeValues[]" \
    --output text)"
done

The first loop covers disk (EBS) snapshots, the second database snapshots. In both, “all” after a snapshot means anyone can copy it.

Fix
aws ec2 modify-snapshot-attribute --snapshot-id snap-0123456789abcdef0 \
  --attribute createVolumePermission --operation-type remove --group-names all

aws rds modify-db-snapshot-attribute --db-snapshot-identifier my-snapshot \
  --attribute-name restore --values-to-remove all

7.Servers that still answer IMDSv1

The instance metadata service hands a server its role's credentials. With the old version, IMDSv1, a single forged request from any app on the server can fetch them. IMDSv2 requires a session token that such a request can't get.

Check
aws ec2 describe-instances \
  --query "Reservations[].Instances[?MetadataOptions.HttpTokens=='optional'][].[InstanceId,IamInstanceProfile.Arn]" \
  --output table

Every row accepts IMDSv1. Rows with a role ARN are the ones whose credentials are at stake.

Fix
aws ec2 modify-instance-metadata-options --instance-id i-0123456789abcdef0 \
  --http-tokens required --http-endpoint enabled

aws ec2 modify-instance-metadata-defaults --http-tokens required

The second command makes IMDSv2 the default for new instances in the region. Update old SDKs and agents on the server first; current versions all support IMDSv2.

8.Users and roles with full admin access

Admin access granted “for now” rarely gets taken back. Whoever gets hold of that user's keys, or the server running as that role, controls the whole account.

Check
aws iam list-entities-for-policy \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
  --query "{users:PolicyUsers[].UserName,groups:PolicyGroups[].GroupName,roles:PolicyRoles[].RoleName}"

Anything listed has full access. This only finds the AWS-managed AdministratorAccess policy; policies that allow “*” on “*” are just as powerful and need a closer look.

Fix
aws iam detach-role-policy --role-name app-role \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess

Attach a policy scoped to what the workload does before you detach admin. IAM Access Analyzer can generate one from its CloudTrail history.

9.Access keys nobody uses

Every long-lived access key is a password that never expires. Keys nobody has used in months are pure risk: no one would notice if they started being used again.

Check
aws iam generate-credential-report > /dev/null
aws iam get-credential-report --query Content --output text \
  | base64 --decode | cut -d, -f1,9,11,14,16

The columns are user, key 1 active, key 1 last used, key 2 active and key 2 last used. Look for active keys last used months ago, or never (N/A). If the report isn't ready yet, run the second command again after a few seconds.

Fix
aws iam update-access-key --user-name ci-legacy \
  --access-key-id AKIAIOSFODNN7EXAMPLE --status Inactive

Deactivate first and wait a week. If nothing breaks, delete the key with aws iam delete-access-key.

10.A root user with access keys, or without MFA

The root user can do anything, including closing the account, and no policy can limit it. It should have no access keys and should always need MFA to sign in.

Check
aws iam get-account-summary \
  --query "SummaryMap.{rootAccessKeys:AccountAccessKeysPresent,rootMfa:AccountMFAEnabled}"

You want rootAccessKeys 0 and rootMfa 1.

Fix: Sign in as the root user, open Security credentials, delete any access keys, and assign an MFA device. This one can't be done from the CLI.

11.Roles anyone can assume

A role's trust policy decides who can become it. “AWS”: “*” lets any AWS account in the world try, and a GitHub Actions trust without a repository condition lets any repository on GitHub assume it.

Check
aws iam list-roles \
  --query "Roles[?contains(to_string(AssumeRolePolicyDocument),'\"AWS\":\"*\"') || contains(to_string(AssumeRolePolicyDocument),'token.actions.githubusercontent.com')].RoleName" \
  --output text

aws iam get-role --role-name deploy --query Role.AssumeRolePolicyDocument

The first command lists roles that trust everyone or trust GitHub Actions. For each GitHub role, check with the second command that its trust policy has a token.actions.githubusercontent.com:sub condition naming your own repository.

Fix
# In trust.json, add to the GitHub statement:
#   "Condition": {
#     "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
#     "StringLike":   { "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*" }
#   }
aws iam update-assume-role-policy --role-name deploy --policy-document file://trust.json

12.Functions anyone on the internet can call

A Lambda function URL with auth type NONE is a public HTTPS endpoint. Whatever the function can do with its role, anyone with the URL can make it do.

Check
for f in $(aws lambda list-functions --query 'Functions[].FunctionName' --output text); do
  aws lambda list-function-url-configs --function-name "$f" \
    --query "FunctionUrlConfigs[?AuthType=='NONE'].FunctionUrl" --output text
done

Each URL printed can be called by anyone. Some are meant to be public, such as webhooks; make sure each one is, and that the function checks what it receives.

Fix
aws lambda update-function-url-config --function-name my-function --auth-type AWS_IAM

If nothing should call it over the internet, remove the URL instead: aws lambda delete-function-url-config --function-name my-function.

Would you notice the next one?

These checks show your account as it is today. The next deploy, the next quick test, or the next agent given an API key can undo any of them. First make sure every change leaves a record:

Check
aws cloudtrail describe-trails \
  --query "trailList[?IsMultiRegionTrail].[Name,S3BucketName]" --output table

You want at least one multi-region trail. Without it, a change in a region you don't watch leaves no record.

Then something has to read that record. Heawood checks every change against these twelve and 64 more, as each change lands, and tells you who made it and how to fix it. Request a pilot.

Get new checks by email

We add to this list as we find new ways data leaks. One short email when we do.