Tarek Cheikh
Founder & AWS Cloud Architect
Part 3 of 3 in the EC2 Security Series
You ran the scanner from Part 2. You got your scores: each instance graded from 0 to 100 across 46 checks in 8 categories (A through H), plus a separate environment score for account and VPC posture. Some of those scores aren't great.
Now let's fix everything.
This guide maps directly to the scanner's findings. AWS CLI commands, Terraform snippets, and console steps you can use right now.
A word of caution: Some of these changes (VPC Block Public Access, default security group lockdown, IMDSv2 enforcement) can break running workloads if applied blindly. Test in staging first. Audit before you enforce.
This is the single most impactful fix you can make. IMDSv1 was the attack vector behind the Capital One breach.
For existing instances:
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-endpoint enabled
For all instances in a region (audit IMDSv1 usage first, enforcing this will break apps that rely on IMDSv1):
# First, find instances still using IMDSv1
aws ec2 describe-instances \
--query "Reservations[*].Instances[?MetadataOptions.HttpTokens!='required'].[InstanceId,Tags[?Key=='Name'].Value|[0]]" \
--output table
# Then enforce IMDSv2 on all instances
for id in $(aws ec2 describe-instances \
--query "Reservations[*].Instances[*].InstanceId" \
--output text); do
aws ec2 modify-instance-metadata-options \
--instance-id "$id" \
--http-tokens required \
--http-endpoint enabled
done
For launch templates (A.2):
aws ec2 create-launch-template-version \
--launch-template-id lt-0123456789abcdef0 \
--source-version '$Latest' \
--launch-template-data '{"MetadataOptions":{"HttpTokens":"required","HttpEndpoint":"enabled"}}'
Terraform:
resource "aws_instance" "example" {
metadata_options {
http_tokens = "required"
http_endpoint = "enabled"
}
}
Account-wide default (prevents new instances from using IMDSv1):
aws ec2 modify-instance-metadata-defaults \
--region us-east-1 \
--http-tokens required
If your instance doesn't need to be directly reachable from the internet, remove the public IP.
# Disassociate an Elastic IP
aws ec2 disassociate-address --association-id eipassoc-0123456789abcdef0
# For auto-assigned public IPs: stop the instance, change the subnet setting,
# or launch in a private subnet behind a NAT Gateway or VPC endpoint.
Better approach: use AWS Systems Manager Session Manager for access instead of SSH over public IPs.
Every EC2 instance that talks to AWS services needs an IAM role. No hardcoded credentials.
aws ec2 associate-iam-instance-profile \
--instance-id i-0123456789abcdef0 \
--iam-instance-profile Name=my-instance-role
There's no “fix” button for this. You need to:
# Store a secret in SSM Parameter Store
aws ssm put-parameter \
--name "/myapp/db-password" \
--type SecureString \
--value "your-password"
# Fetch it in UserData at boot time
DB_PASS=$(aws ssm get-parameter \
--name "/myapp/db-password" \
--with-decryption \
--query "Parameter.Value" \
--output text)
Then clear the old UserData (instance must be stopped):
aws ec2 stop-instances --instance-ids i-0123456789abcdef0
aws ec2 modify-instance-attribute \
--instance-id i-0123456789abcdef0 \
--attribute userData \
--value ""
aws ec2 start-instances --instance-ids i-0123456789abcdef0
The VPC default security group should have zero rules. No inbound, no outbound.
# Get default SG ID
DEFAULT_SG=$(aws ec2 describe-security-groups \
--filters "Name=group-name,Values=default" \
"Name=vpc-id,Values=vpc-0123456789abcdef0" \
--query "SecurityGroups[0].GroupId" --output text)
# Revoke all inbound rules
aws ec2 revoke-security-group-ingress \
--group-id "$DEFAULT_SG" \
--ip-permissions "$(aws ec2 describe-security-groups \
--group-ids "$DEFAULT_SG" \
--query 'SecurityGroups[0].IpPermissions' --output json)"
# Revoke all outbound rules
aws ec2 revoke-security-group-egress \
--group-id "$DEFAULT_SG" \
--ip-permissions "$(aws ec2 describe-security-groups \
--group-ids "$DEFAULT_SG" \
--query 'SecurityGroups[0].IpPermissionsEgress' --output json)"
Remove rules that allow 0.0.0.0/0 or ::/0 to sensitive ports.
# Remove SSH from world
aws ec2 revoke-security-group-ingress \
--group-id sg-0123456789abcdef0 \
--protocol tcp --port 22 --cidr 0.0.0.0/0
Replace with specific CIDR ranges or use EC2 Instance Connect / SSM Session Manager.
aws ec2 create-flow-logs \
--resource-type VPC \
--resource-ids vpc-0123456789abcdef0 \
--traffic-type ALL \
--log-destination-type cloud-watch-logs \
--log-group-name /vpc/flow-logs \
--deliver-logs-permission-arn arn:aws:iam::123456789012:role/flow-logs-role
Terraform:
resource "aws_flow_log" "vpc" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination = aws_cloudwatch_log_group.flow_logs.arn
iam_role_arn = aws_iam_role.flow_logs.arn
}
Don't allow all outbound traffic by default. Restrict to what your application actually needs.
# Remove the default "allow all" egress rule
aws ec2 revoke-security-group-egress \
--group-id sg-0123456789abcdef0 \
--ip-permissions '[{"IpProtocol":"-1","IpRanges":[{"CidrIp":"0.0.0.0/0"}]}]'
# Add specific egress rules (e.g., HTTPS only)
aws ec2 authorize-security-group-egress \
--group-id sg-0123456789abcdef0 \
--protocol tcp --port 443 --cidr 0.0.0.0/0
Account-level default (all new volumes encrypted automatically):
aws ec2 enable-ebs-encryption-by-default --region us-east-1
Do this in every region:
for region in $(aws ec2 describe-regions --query "Regions[*].RegionName" --output text); do
aws ec2 enable-ebs-encryption-by-default --region "$region"
echo "Enabled EBS encryption in $region"
done
For existing unencrypted volumes, you need to create an encrypted snapshot and replace the volume.
# Find public snapshots (describe-snapshots doesn't include permissions,
# so we check each snapshot individually)
for snap in $(aws ec2 describe-snapshots --owner-ids self \
--query "Snapshots[*].SnapshotId" --output text); do
PERM=$(aws ec2 describe-snapshot-attribute \
--snapshot-id "$snap" \
--attribute createVolumePermission \
--query "CreateVolumePermissions[?Group=='all']" \
--output text)
[ -n "$PERM" ] && echo "PUBLIC: $snap"
done
# Remove public access
aws ec2 modify-snapshot-attribute \
--snapshot-id snap-0123456789abcdef0 \
--attribute createVolumePermission \
--operation-type remove \
--group-names all
# Find your public AMIs
aws ec2 describe-images --owners self \
--query "Images[?Public==`true`].[ImageId,Name]" --output table
# Make them private
aws ec2 modify-image-attribute \
--image-id ami-0123456789abcdef0 \
--launch-permission "Remove=[{Group=all}]"
Check what's attached:
ROLE_NAME="my-instance-role"
aws iam list-attached-role-policies --role-name "$ROLE_NAME"
Remove overprivileged policies:
aws iam detach-role-policy \
--role-name "$ROLE_NAME" \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Replace with least-privilege policies. Use IAM Access Analyzer to generate policies based on actual usage:
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/my-instance-role"}'
aws ec2 disable-serial-console-access --region us-east-1
aws cloudtrail create-trail \
--name management-trail \
--s3-bucket-name my-cloudtrail-bucket \
--is-multi-region-trail \
--enable-log-file-validation
aws cloudtrail start-logging --name management-trail
Install the SSM Agent (most Amazon Linux and Windows AMIs have it pre-installed):
# Verify SSM agent is running
aws ssm describe-instance-information \
--query "InstanceInformationList[*].[InstanceId,PingStatus]" \
--output table
The instance's IAM role needs the AmazonSSMManagedInstanceCore policy.
aws guardduty create-detector \
--enable \
--features '[{"Name":"RUNTIME_MONITORING","Status":"ENABLED"},{"Name":"EBS_MALWARE_PROTECTION","Status":"ENABLED"}]'
# Create a patch baseline
aws ssm create-patch-baseline \
--name "production-baseline" \
--approval-rules '{"PatchRules":[{"PatchFilterGroup":{"PatchFilters":[{"Key":"SEVERITY","Values":["Critical","Important"]}]},"ApproveAfterDays":7}]}'
# Run patching now
aws ssm send-command \
--document-name "AWS-RunPatchBaseline" \
--targets "Key=instanceids,Values=i-0123456789abcdef0" \
--parameters "Operation=Install"
AMIs older than 180 days are flagged. Build fresh AMIs regularly:
# Create a new AMI from a patched instance
aws ec2 create-image \
--instance-id i-0123456789abcdef0 \
--name "my-app-$(date +%Y%m%d)" \
--no-reboot
Better: use EC2 Image Builder to automate AMI pipelines.
aws inspector2 enable --resource-types EC2
# Find unused EIPs
aws ec2 describe-addresses \
--query "Addresses[?AssociationId==null].[AllocationId,PublicIp]" \
--output table
# Release them
aws ec2 release-address --allocation-id eipalloc-0123456789abcdef0
aws ec2 modify-subnet-attribute \
--subnet-id subnet-0123456789abcdef0 \
--no-map-public-ip-on-launch
aws ec2 modify-vpc-block-public-access-options \
--internet-gateway-block-mode block-bidirectional
aws ec2 modify-transit-gateway \
--transit-gateway-id tgw-0123456789abcdef0 \
--options AutoAcceptSharedAttachments=disable
aws ec2 create-tags \
--resources i-0123456789abcdef0 \
--tags Key=Name,Value=my-app-server \
Key=Environment,Value=production \
Key=Owner,Value=platform-team
Enforce tags at the organization level with AWS Organizations tag policies.
Instances stopped for over 30 days are flagged. Either:
# Create AMI before terminating
aws ec2 create-image --instance-id i-0123456789abcdef0 \
--name "backup-before-termination-$(date +%Y%m%d)"
# Then terminate
aws ec2 terminate-instances --instance-ids i-0123456789abcdef0
# The scanner flags SGs not attached to any ENI
# Verify and delete
aws ec2 delete-security-group --group-id sg-0123456789abcdef0
Don't try to fix everything at once. Here's the order that matters:
Don't do this manually every time. Set up guardrails:
# Example: weekly scan via cron
0 6 * * 1 ec2-security-scanner security -p production -r us-east-1 -q
That's the full EC2 security series. Part 1 showed you the risks. Part 2 gave you the scanner. Part 3 gave you the fixes.
46 checks. 137 controls. Every fix you need. No excuses left.
This series and the scanner behind it are open source and free. If they helped you lock down your account, here is how to give back:
GitHub: github.com/TocConsulting/ec2-security-scanner | PyPI: pypi.org/project/ec2-security-scanner
This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.
Spin up a local AWS, plant deliberately insecure resources, and run real security scanners against it. No account, no token, no cost, no risk.
Part 2 of 3 in the EC2 Security Series. One open-source command that scores every EC2 instance 0-100 across 46 checks and maps each finding to 137 controls in 10 compliance frameworks.
Part 1 of 3 in the EC2 Security Series. The real EC2 attack surface, from IMDSv1 and secrets in UserData to public snapshots, and the breaches that prove it matters.