Audit & Least Privilege
Use IAM Access Analyzer, policy simulation, and CloudTrail to audit permissions and tighten least-privilege policies.
Prerequisites
Mental Model: The Audit Loop
Every production IAM hygiene practice fits into one of four phases. This lab covers all four.
IAM Access Analyzer
Goal
Create an Access Analyzer, generate findings by creating an overly permissive resource policy, interpret the finding JSON, archive a finding, and understand the difference between external access analysis and unused access analysis.
Estimated time: 1–2 hours
What Access Analyzer does
What's happening here
IAM Access Analyzer continuously examines resource-based policies (S3 bucket policies, SQS queue policies, KMS key policies, IAM role trust policies, Lambda resource policies, etc.) and identifies resources that are accessible from outside your zone of trust: by default, your AWS account. Any resource that grants access to a principal in another account, to the public (Principal: *), or to an AWS service externally, generates a finding. Findings are not errors, they're observations. Some are intentional (a public S3 website bucket). Others are accidents. Access Analyzer has two modes: external access (what's reachable from outside) and unused access (what permissions exist but haven't been used). External access is free. Unused access costs per IAM role/user analyzed per day.
# Create an Access Analyzer for your account
# Zone of trust = ACCOUNT (findings = anything accessible from outside this account)
export ANALYZER_NAME="${LAB_PREFIX}-analyzer"
ANALYZER_ARN=$(aws accessanalyzer create-analyzer \
--analyzer-name $ANALYZER_NAME \
--type ACCOUNT \
--region $AWS_REGION \
--query 'arn' --output text)
echo "Analyzer ARN: $ANALYZER_ARN"
# List all analyzers in the region
aws accessanalyzer list-analyzers \
--region $AWS_REGION | jq '.analyzers[] | {name, arn, type, status}'type: ACCOUNT means the zone of trust is your account, any cross-account or public access generates a finding. If you were in an AWS Organization, you could set type to ORGANIZATION and the zone of trust would be the whole org.
Checkpoint: analyzer created with status ACTIVE.
Generate a finding: overly permissive S3 bucket policy
What's happening here
Access Analyzer scans resource-based policies continuously. When you attach a policy that grants access to a principal outside your zone of trust (another account, *, or an AWS service without aws:SourceAccount scoping), it generates a finding within minutes. We'll deliberately create an S3 bucket policy that allows public GetObject (a common accidental misconfiguration) and watch the finding appear.
# Create an S3 bucket
export AUDIT_BUCKET="${LAB_PREFIX}-audit-demo"
aws s3api create-bucket \
--bucket $AUDIT_BUCKET \
--region $AWS_REGION
echo "Bucket: $AUDIT_BUCKET"
# Disable block public access on this bucket (so we can attach the permissive policy)
aws s3api put-public-access-block \
--bucket $AUDIT_BUCKET \
--public-access-block-configuration \
BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
# Attach a public read policy (classic accidental misconfiguration)
cat > /tmp/public-read-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AccidentalPublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${AUDIT_BUCKET}/*"
}]
}
EOF
aws s3api put-bucket-policy \
--bucket $AUDIT_BUCKET \
--policy file:///tmp/public-read-policy.json
echo "Permissive policy applied. Access Analyzer will generate a finding within 1-5 minutes."
echo "Waiting 90 seconds before checking for findings..."
sleep 90# List findings from the analyzer
aws accessanalyzer list-findings \
--analyzer-arn $ANALYZER_ARN \
--region $AWS_REGION | jq '.findings[] | {
id: .id,
resourceType: .resourceType,
resource: .resource,
status: .status,
isPublic: .isPublic,
principal: .principal
}'
# Get the finding ID
FINDING_ID=$(aws accessanalyzer list-findings \
--analyzer-arn $ANALYZER_ARN \
--region $AWS_REGION \
--query 'findings[?resourceType==`AWS::S3::Bucket`].id' \
--output text | head -1)
echo "Finding ID: $FINDING_ID"
# Get full finding details
aws accessanalyzer get-finding \
--analyzer-arn $ANALYZER_ARN \
--id $FINDING_ID \
--region $AWS_REGION | jq '.finding | {
resource: .resource,
resourceType: .resourceType,
condition: .condition,
action: .action,
isPublic: .isPublic,
status: .status,
analyzedAt: .analyzedAt
}'finding shows isPublic: true, principal: {"*": "*"}, and action: ["s3:GetObject"]. The finding tells you exactly which principal has which access, not just "something is wrong" but the precise vector.
Fix the policy, watch the finding auto-resolve
What's happening here
Access Analyzer findings have a lifecycle: ACTIVE → RESOLVED (resource policy changed to remove the access) or ARCHIVED (you've acknowledged it's intentional). When you fix the policy, Access Analyzer detects the change and auto-resolves the finding within minutes. If you archive a finding without fixing it, it remains accessible but won't appear in the active queue, archive is for intentional findings like "this S3 bucket is a public static website".