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.
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.
aws ec2 revoke-security-group-ingress --group-id sg-0123456789abcdef0 \
--protocol tcp --port 5432 --cidr 0.0.0.0/0Then 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.
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 tableThe 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.
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.
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.
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=trueIf 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”.
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"
donepublic-by-policy=True, or public-by-acl above 0, means anyone on the internet can reach the bucket.
aws s3api put-public-access-block --bucket my-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true5.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.
aws rds describe-db-instances \
--query "DBInstances[?PubliclyAccessible].[DBInstanceIdentifier,Engine,Endpoint.Address]" \
--output tableEvery row is a database with a public address. Run it in each region you use.
aws rds modify-db-instance --db-instance-identifier my-db \
--no-publicly-accessible --apply-immediately6.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.
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)"
doneThe first loop covers disk (EBS) snapshots, the second database snapshots. In both, “all” after a snapshot means anyone can copy it.
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 all7.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.
aws ec2 describe-instances \
--query "Reservations[].Instances[?MetadataOptions.HttpTokens=='optional'][].[InstanceId,IamInstanceProfile.Arn]" \
--output tableEvery row accepts IMDSv1. Rows with a role ARN are the ones whose credentials are at stake.
aws ec2 modify-instance-metadata-options --instance-id i-0123456789abcdef0 \
--http-tokens required --http-endpoint enabled
aws ec2 modify-instance-metadata-defaults --http-tokens requiredThe 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.
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.
aws iam detach-role-policy --role-name app-role \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccessAttach 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.
aws iam generate-credential-report > /dev/null
aws iam get-credential-report --query Content --output text \
| base64 --decode | cut -d, -f1,9,11,14,16The 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.
aws iam update-access-key --user-name ci-legacy \
--access-key-id AKIAIOSFODNN7EXAMPLE --status InactiveDeactivate 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.
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.
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.AssumeRolePolicyDocumentThe 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.
# 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.json12.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.
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
doneEach 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.
aws lambda update-function-url-config --function-name my-function --auth-type AWS_IAMIf 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:
aws cloudtrail describe-trails \
--query "trailList[?IsMultiRegionTrail].[Name,S3BucketName]" --output tableYou 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.