Security all 3 written
Least privilege worked out by hand, secrets that never belong in a repo, and the misconfigurations that actually get exploited.
42 Least privilege, evaluated policy by policy
"Least privilege" is not a slogan -- it is a specific, checkable property of a policy: does this statement's action pattern match ONLY what the job actually needs, or does it also match things nobody asked for. This lesson builds a real pattern matcher (the same shape AWS's own evaluator uses for wildcards) and runs it against two real, differently-scoped policies attached to two real IAM users.
What a wildcard actually matches -- checked, not assumed
s3:* and s3:Get* look similarly permissive at a glance. They
are not: one matches every S3 action that exists, past and future; the other matches only
actions starting with Get. The difference is the entire content of "least
privilege" as a mechanical fact, not a style preference.
# A wildcard-aware action matcher: converts an IAM-style pattern (where * means
# "any characters") into a real regex, the same shape AWS's own policy engine uses.
action_matches() {
local pattern="$1" action="$2"
local regex="^$(echo "$pattern" | sed 's/\*/.*/g')$"
[[ "$action" =~ $regex ]]
}
echo "does s3:* match s3:GetObject? $(action_matches 's3:*' 's3:GetObject' && echo yes || echo no)"
echo "does s3:* match s3:DeleteObject? $(action_matches 's3:*' 's3:DeleteObject' && echo yes || echo no)"
echo "does s3:Get* match s3:GetObject? $(action_matches 's3:Get*' 's3:GetObject' && echo yes || echo no)"
echo "does s3:Get* match s3:DeleteObject? $(action_matches 's3:Get*' 's3:DeleteObject' && echo yes || echo no)"
check "a bare wildcard matches a destructive action nobody intended to grant" \
"$(action_matches 's3:*' 's3:DeleteObject' && echo yes || echo no)" "yes"
check "a scoped prefix wildcard does NOT match an action outside that prefix" \
"$(action_matches 's3:Get*' 's3:DeleteObject' && echo yes || echo no)" "no"
What it printed, when the build ran it
does s3:* match s3:GetObject? yes does s3:* match s3:DeleteObject? yes does s3:Get* match s3:GetObject? yes does s3:Get* match s3:DeleteObject? no ok a bare wildcard matches a destructive action nobody intended to grant ok a scoped prefix wildcard does NOT match an action outside that prefix
This is why s3:* on Resource: * is the single most
common real over-grant. It is not usually written maliciously -- it is written
because it is the fastest way to make an error message go away during development, and
then never revisited. The check above is exactly the audit question: for every wildcard
in a real policy, what does it ACTUALLY match, not what was it written to allow.
Two real users, two real policies, one real measured difference
Both users below can genuinely read the one object they need. Only one of them can also delete anything in the entire account's S3 -- confirmed by reading each policy back from IAM itself, not by trusting what either policy was intended to do.
aws iam create-user --user-name wide-user > /dev/null
aws iam create-user --user-name scoped-user > /dev/null
cat > /tmp/cd-course-wide-policy.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]
}
JSON
cat > /tmp/cd-course-scoped-policy.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::reports-bucket/*"}]
}
JSON
aws iam put-user-policy --user-name wide-user --policy-name wide \
--policy-document file:///tmp/cd-course-wide-policy.json
aws iam put-user-policy --user-name scoped-user --policy-name scoped \
--policy-document file:///tmp/cd-course-scoped-policy.json
echo "wide-user's real, stored policy:"
wide=$(aws iam get-user-policy --user-name wide-user --policy-name wide --output json)
echo "$wide"
echo
echo "scoped-user's real, stored policy:"
scoped=$(aws iam get-user-policy --user-name scoped-user --policy-name scoped --output json)
echo "$scoped"
rm -f /tmp/cd-course-wide-policy.json /tmp/cd-course-scoped-policy.json
check "wide-user's policy really does grant every S3 action, on every resource" \
"$(echo "$wide" | grep -c '"s3:\*"')" "1"
check "scoped-user's policy grants exactly one action, on exactly one bucket's objects" \
"$(echo "$scoped" | grep -c '"s3:GetObject"')" "1"
check "scoped-user's policy does NOT contain a bare resource wildcard" \
"$(echo "$scoped" | grep -c '"Resource": "\*"')" "0"
What it printed, when the build ran it
wide-user's real, stored policy:
{
"UserName": "wide-user",
"PolicyName": "wide",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}
}
scoped-user's real, stored policy:
{
"UserName": "scoped-user",
"PolicyName": "scoped",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::reports-bucket/*"
}
]
}
}
ok wide-user's policy really does grant every S3 action, on every resource
ok scoped-user's policy grants exactly one action, on exactly one bucket's objects
ok scoped-user's policy does NOT contain a bare resource wildcard
The same real check, applied to a live credential's age
An access key with no rotation is a real, dated fact IAM tracks for you -- not a theoretical concern. Reading it back is one API call.
key=$(aws iam create-access-key --user-name wide-user --output json)
echo "a real access key, just created, with a real timestamp:"
echo "$key"
created=$(echo "$key" | grep -o '"CreateDate": "[^"]*"' | cut -d'"' -f4)
echo
echo "the SAME fact, read back independently via list-access-keys, not trusted from creation alone:"
listed=$(aws iam list-access-keys --user-name wide-user --output json)
echo "$listed"
check "the access key's creation date is independently confirmed on a second, separate read" \
"$(echo "$listed" | grep -c "$created")" "1"
What it printed, when the build ran it
a real access key, just created, with a real timestamp:
{
"AccessKey": {
"UserName": "wide-user",
"AccessKeyId": "AKIARZPUZDIKNI5TXXFQ",
"Status": "Active",
"SecretAccessKey": "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",
"CreateDate": "2026-09-15T20:29:07.290880Z"
}
}
the SAME fact, read back independently via list-access-keys, not trusted from creation alone:
{
"AccessKeyMetadata": [
{
"UserName": "wide-user",
"AccessKeyId": "AKIARZPUZDIKNI5TXXFQ",
"Status": "Active",
"CreateDate": "2026-09-15T20:29:07.290880Z"
}
]
}
ok the access key's creation date is independently confirmed on a second, separate read
Why this matters beyond "old things feel risky." A credential's age
is directly checkable, and a real security review asks exactly this question at scale:
which access keys are old enough that nobody remembers issuing them, and does anyone
still need them. AWS's own IAM console surfaces this exact field for precisely this
review -- it is not a metaphor, it is the same CreateDate just read back
above.
Least privilege, as a checklist rather than a feeling
- Does every action in the policy match ONLY what the role actually performs -- checked with the same pattern logic run above, not assumed from the policy's name.
- Is every
Resourcea specific ARN or prefix, not a bare*, unless the job genuinely spans every resource of that type. - Is there a credential (an access key, checked above) old enough that its continued existence is now a habit rather than a decision anyone actively made.
43 Secrets management: what never belongs in a repo
"I deleted the file in the next commit" does not delete a secret -- git's entire design keeps every version of every file forever, findable, unless the history itself is rewritten (a real, disruptive operation, not a quiet fix). This lesson proves that directly, then builds what should have held the secret from the start.
A secret committed, then "removed" -- and still there
Deleting a file in a new commit removes it from the CURRENT checkout. It does nothing to the earlier commit that still has it, and that earlier commit is still part of the repository's real history, reachable by anyone with clone access.
work=$(mktemp -d)
cd "$work"
git init -q -b main
git config user.name course
git config user.email course@example.com
echo "API_KEY=sk-real-looking-secret-abc123" > config.env
git add config.env
git commit -q -m "add config"
echo "someone notices, and 'fixes' it the way people usually do:"
git rm -q config.env
echo "config.env" > .gitignore
git add .gitignore
git commit -q -m "remove secret, add to gitignore"
echo
echo "the file is gone from the current checkout:"
ls config.env 2>&1
echo
echo "but searching the repository's real history for that exact string:"
found_in_history=$(git log --all -p -- config.env | grep -c "sk-real-looking-secret-abc123")
echo "matches found in history: $found_in_history"
cd / && rm -rf "$work"
check "the file is genuinely absent from the current checkout" \
"$([ ! -f config.env ] && echo yes)" "yes"
check "the exact secret string is still recoverable from git history, unchanged" \
"$([ "$found_in_history" -ge 1 ] && echo yes)" "yes"
What it printed, when the build ran it
someone notices, and 'fixes' it the way people usually do: the file is gone from the current checkout: ls: config.env: No such file or directory but searching the repository's real history for that exact string: matches found in history: 2 ok the file is genuinely absent from the current checkout ok the exact secret string is still recoverable from git history, unchanged
What actually removes a secret from git history. Not
git rm, not .gitignore (which only stops FUTURE commits from
including a file) -- only rewriting history (git filter-repo, or
equivalent) and force-pushing every branch, PLUS rotating the leaked secret itself,
because anyone who already cloned or fetched the repo before the rewrite kept a copy.
The only fully reliable fix is treating the leaked value as compromised and issuing a
new one -- exactly what lesson 42's key-age check exists to make routine.
A real secret store: rotation and an audit trail a committed file cannot have
AWS Secrets Manager is not "a Secret object with extra steps" -- it supports real rotation (issuing a new value on a schedule, without redeploying anything that reads it by name) and every read is a real, individually logged API call.
echo "storing a real secret, for real, the same way an application would fetch it at runtime:"
aws secretsmanager create-secret --name db-password --secret-string 'real-secret-value' --output json
echo
echo "reading it back -- this is what an application does at startup, not a person browsing a file:"
fetched=$(aws secretsmanager get-secret-value --secret-id db-password --output json)
echo "$fetched"
echo
echo "rotating it -- a NEW version exists, with its own ID, while the secret's NAME never changes:"
before_version=$(echo "$fetched" | grep -o '"VersionId": "[^"]*"' | cut -d'"' -f4)
rotated=$(aws secretsmanager rotate-secret --secret-id db-password --output json)
after_version=$(echo "$rotated" | grep -o '"VersionId": "[^"]*"' | cut -d'"' -f4)
echo "$rotated"
echo
echo "version before rotation: $before_version"
echo "version after rotation: $after_version"
check "the secret was really stored and read back correctly" \
"$(echo "$fetched" | grep -c 'real-secret-value')" "1"
check "rotation produced a genuinely different version, with the same secret name unchanged" \
"$([ "$before_version" != "$after_version" ] && echo yes)" "yes"
What it printed, when the build ran it
storing a real secret, for real, the same way an application would fetch it at runtime:
{
"ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AdUEDj",
"Name": "db-password",
"VersionId": "144acdc5-b26c-4454-a772-154a2e82e902"
}
reading it back -- this is what an application does at startup, not a person browsing a file:
{
"ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AdUEDj",
"Name": "db-password",
"VersionId": "144acdc5-b26c-4454-a772-154a2e82e902",
"SecretString": "real-secret-value",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": 1789504149
}
rotating it -- a NEW version exists, with its own ID, while the secret's NAME never changes:
{
"ARN": "arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AdUEDj",
"Name": "db-password",
"VersionId": "7fa32aab-b33a-4eb4-a8d9-48db1574ffbc"
}
version before rotation: 144acdc5-b26c-4454-a772-154a2e82e902
version after rotation: 7fa32aab-b33a-4eb4-a8d9-48db1574ffbc
ok the secret was really stored and read back correctly
ok rotation produced a genuinely different version, with the same secret name unchanged
Why "the same name, a new version" is the entire point. Every
application reading db-password by name automatically gets the new value
on its next fetch -- nobody has to hunt down and update a config file anywhere. A
committed secret has no equivalent: rotating it means editing and redeploying every
place that ever hardcoded the old value, which is exactly why it usually does not
happen until something forces it.
Finding a leaked secret by its real shape, not by reading every line
Real AWS access key IDs have a fixed, recognisable shape --
AKIA followed by 16 characters. A real scanner does not need to understand
what a file does to flag this; the pattern alone is the entire signal.
work=$(mktemp -d)
cd "$work"
cat > app.py << 'PY'
import boto3
client = boto3.client(
"s3",
aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
PY
echo "the file, exactly as someone might commit it:"
cat app.py
echo
echo "scanning for the real AWS access key ID shape -- no special knowledge of THIS file needed:"
found=$(grep -noE 'AKIA[0-9A-Z]{16}' app.py)
echo "$found"
cd / && rm -rf "$work"
check "the scanner found the real access key by its shape alone" \
"$(echo "$found" | grep -c 'AKIAIOSFODNN7EXAMPLE')" "1"
What it printed, when the build ran it
the file, exactly as someone might commit it:
import boto3
client = boto3.client(
"s3",
aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
scanning for the real AWS access key ID shape -- no special knowledge of THIS file needed:
4:AKIAIOSFODNN7EXAMPLE
ok the scanner found the real access key by its shape alone
This is exactly what tools like gitleaks and trufflehog automate. The regex above is deliberately simple; real scanners carry hundreds of such patterns (AWS keys, GitHub tokens, private key headers, database connection strings) and run on every commit in CI -- the direct, automated version of the manual check just run. A repo with that scan wired into its pipeline (Part 10) catches this before the commit ever merges, not months later during an audit.
What actually belongs in a repo, and what never does
- Fine in a repo: which secret NAME an app reads (
db-password, checked above) -- that is a reference, not the value itself. - Never in a repo: the actual value of any credential, API key, or password, in any commit, even one later "removed" -- checked above as recoverable regardless.
- The real fix once a secret has leaked: rotate it (this lesson), not just delete the file (proven not to work) -- the leaked value must be treated as already compromised the moment it was committed, whether or not anyone is known to have seen it.
44 The misconfigurations that actually get exploited
Sophisticated attacks make the news. The overwhelming majority of real cloud breaches do not need one -- they need a setting that was left open, found by a scan that takes seconds to run against an account nobody thought to check. This lesson builds three of the most common ones for real, confirms each is genuinely findable by an automated scan (not a human squinting at a policy), and then fixes each one the same way a real remediation would.
Attack 1: a security group open to the entire internet, on a port that matters
Port 22 (SSH) or 3389 (RDP), open to 0.0.0.0/0, is the single most common
finding in every public cloud security scan report. It is rarely intentional -- it is
usually opened once, during setup, to make a connection error go away, and never revisited.
The scan below does not read every rule by eye; it asks AWS directly for exactly the
combination that matters.
vpc_id=$(aws ec2 create-vpc --cidr-block 10.4.0.0/16 --query 'Vpc.VpcId' --output text)
echo "three real security groups, the way a small team's account actually accumulates them:"
risky=$(aws ec2 create-security-group --group-name legacy-bastion --description "old jump box" --vpc-id "$vpc_id" --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id "$risky" --protocol tcp --port 22 --cidr 0.0.0.0/0 > /dev/null
web=$(aws ec2 create-security-group --group-name web-tier --description "public web" --vpc-id "$vpc_id" --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id "$web" --protocol tcp --port 443 --cidr 0.0.0.0/0 > /dev/null
scoped=$(aws ec2 create-security-group --group-name admin-access --description "scoped ssh" --vpc-id "$vpc_id" --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id "$scoped" --protocol tcp --port 22 --cidr 203.0.113.0/24 > /dev/null
echo
echo "the real scan: ask AWS directly for any group with port 22 open to the whole internet --"
echo "no need to read a single rule by eye:"
flagged=$(aws ec2 describe-security-groups \
--filters "Name=ip-permission.from-port,Values=22" "Name=ip-permission.cidr,Values=0.0.0.0/0" \
--query 'SecurityGroups[].GroupName' --output text)
echo "flagged: $flagged"
echo
echo "fixing it -- revoking the open rule, replacing it with the same scoped range the other group used:"
aws ec2 revoke-security-group-ingress --group-id "$risky" --protocol tcp --port 22 --cidr 0.0.0.0/0 > /dev/null
aws ec2 authorize-security-group-ingress --group-id "$risky" --protocol tcp --port 22 --cidr 203.0.113.0/24 > /dev/null
after=$(aws ec2 describe-security-groups \
--filters "Name=ip-permission.from-port,Values=22" "Name=ip-permission.cidr,Values=0.0.0.0/0" \
--query 'SecurityGroups[].GroupName' --output text)
echo "flagged after the fix: '$after'"
check "the scan found exactly the one group with SSH open to the world" "$flagged" "legacy-bastion"
check "the web tier's port 443 rule did not get caught -- it is a different port, correctly" \
"$(echo "$flagged" | grep -c 'web-tier')" "0"
check "after revoking and re-scoping, the same scan finds nothing left open" "$after" ""
What it printed, when the build ran it
three real security groups, the way a small team's account actually accumulates them: the real scan: ask AWS directly for any group with port 22 open to the whole internet -- no need to read a single rule by eye: flagged: legacy-bastion fixing it -- revoking the open rule, replacing it with the same scoped range the other group used: flagged after the fix: '' ok the scan found exactly the one group with SSH open to the world ok the web tier's port 443 rule did not get caught -- it is a different port, correctly ok after revoking and re-scoping, the same scan finds nothing left open
Why this specific combination. Port 443 open to the world is not a finding -- that is what a public web server is supposed to do. Port 22 or 3389 open to the world is different, because it exposes a login prompt to every scanner on the internet, not just to the team member it was meant for. The fix was never "close port 22" -- it was "know exactly who needs it, and write only that range."
Attack 2: an S3 bucket made public through its POLICY, not its ACL
Lesson 17 blocked public access at the account/bucket level with four settings. This is
the other real path to the same outcome: a bucket policy that grants Principal:
"*" directly. A policy is not a background setting anyone forgets about -- it is
something a person deliberately wrote, most often while trying to make a "just let anyone
read this one file" request work, and then never scoped down.
aws s3api create-bucket --bucket reports-bucket > /dev/null
aws s3api create-bucket --bucket internal-bucket > /dev/null
echo "a policy written to solve a real, narrow problem -- 'let anyone download reports' --"
echo "that ends up granting exactly what it says, to literally anyone:"
cat > /tmp/cd-course-open-policy.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::reports-bucket/*"}]
}
JSON
aws s3api put-bucket-policy --bucket reports-bucket --policy file:///tmp/cd-course-open-policy.json
echo
echo "the real scan: read every bucket's policy back, and flag any with an unrestricted principal --"
echo "internal-bucket has no policy at all, which get-bucket-policy reports as a real error, not silence:"
exposed=""
for bucket in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
policy=$(aws s3api get-bucket-policy --bucket "$bucket" --query Policy --output text 2>/dev/null)
if echo "$policy" | grep -q '"Principal": "\*"'; then
exposed="$exposed $bucket"
fi
done
echo "buckets with a public-read policy: $exposed"
echo
echo "fixing it -- replacing the bare wildcard with the one real principal that actually needs access:"
cat > /tmp/cd-course-scoped-policy.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/reporting-app"}, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::reports-bucket/*"}]
}
JSON
aws s3api put-bucket-policy --bucket reports-bucket --policy file:///tmp/cd-course-scoped-policy.json
fixed=$(aws s3api get-bucket-policy --bucket reports-bucket --query Policy --output text)
echo "$fixed"
rm -f /tmp/cd-course-open-policy.json /tmp/cd-course-scoped-policy.json
check "the scan correctly flagged the bucket with a wildcard principal" "$(echo "$exposed" | xargs)" "reports-bucket"
check "the bucket with no policy at all was correctly left unflagged" "$(echo "$exposed" | grep -c 'internal-bucket')" "0"
check "after the fix, the policy names one real role, not a wildcard" \
"$(echo "$fixed" | grep -c 'reporting-app')" "1"
check "after the fix, the bare wildcard principal is genuinely gone from the policy" \
"$(echo "$fixed" | grep -c '"Principal": "\*"')" "0"
What it printed, when the build ran it
a policy written to solve a real, narrow problem -- 'let anyone download reports' --
that ends up granting exactly what it says, to literally anyone:
the real scan: read every bucket's policy back, and flag any with an unrestricted principal --
internal-bucket has no policy at all, which get-bucket-policy reports as a real error, not silence:
buckets with a public-read policy: reports-bucket
fixing it -- replacing the bare wildcard with the one real principal that actually needs access:
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/reporting-app"}, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::reports-bucket/*"}]
}
ok the scan correctly flagged the bucket with a wildcard principal
ok the bucket with no policy at all was correctly left unflagged
ok after the fix, the policy names one real role, not a wildcard
ok after the fix, the bare wildcard principal is genuinely gone from the policy
This is a different mechanism from lesson 17, on purpose. Public access block settings stop an ACL or policy from taking effect. They do not stop the policy from being WRITTEN in the first place -- a bucket can have every block-public setting turned on and still have a wildcard sitting in its policy document, inert until someone turns a block setting off to "fix an access problem" without checking what the policy underneath actually says. Scanning the policy text itself, as done above, catches that regardless of what the block settings currently say.
Attack 3: an IAM role anyone in the world can assume
A role's trust policy answers one question: who is allowed to become this role. A resource policy (like the S3 one above) controls access to data; a trust policy controls access to an IDENTITY -- and a wildcard principal here means literally any AWS account on earth can request credentials for it, not just anyone inside this one.
echo "a role created with a trust policy nobody meant to leave this wide -- copied from an example, never edited:"
cat > /tmp/cd-course-open-trust.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}]
}
JSON
aws iam create-role --role-name legacy-ci-role --assume-role-policy-document file:///tmp/cd-course-open-trust.json > /dev/null
cat > /tmp/cd-course-scoped-trust.json << 'JSON'
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, "Action": "sts:AssumeRole"}]
}
JSON
aws iam create-role --role-name app-role --assume-role-policy-document file:///tmp/cd-course-scoped-trust.json > /dev/null
echo
echo "the real scan: read every role's trust policy back, and flag any with an unrestricted principal:"
open_roles=""
for role in $(aws iam list-roles --query 'Roles[].RoleName' --output text); do
trust=$(aws iam get-role --role-name "$role" --query 'Role.AssumeRolePolicyDocument' --output json)
if echo "$trust" | grep -q '"Principal": "\*"'; then
open_roles="$open_roles $role"
fi
done
echo "roles anyone on earth can assume: $open_roles"
echo
echo "fixing it -- an existing role's trust policy can be replaced without recreating the role,"
echo "so nothing that already references this role's ARN has to change:"
aws iam update-assume-role-policy --role-name legacy-ci-role --policy-document file:///tmp/cd-course-scoped-trust.json
fixed_trust=$(aws iam get-role --role-name legacy-ci-role --query 'Role.AssumeRolePolicyDocument' --output json)
echo "$fixed_trust"
rm -f /tmp/cd-course-open-trust.json /tmp/cd-course-scoped-trust.json
check "the scan correctly flagged the role with an unrestricted trust policy" "$(echo "$open_roles" | xargs)" "legacy-ci-role"
check "the properly scoped role was correctly left unflagged" "$(echo "$open_roles" | grep -c 'app-role')" "0"
check "after the fix, the role's trust policy no longer contains a bare wildcard principal" \
"$(echo "$fixed_trust" | grep -c '"Principal": "\*"')" "0"
check "the fixed trust policy now names one specific account, not the entire internet" \
"$(echo "$fixed_trust" | grep -c '123456789012')" "1"
What it printed, when the build ran it
a role created with a trust policy nobody meant to leave this wide -- copied from an example, never edited:
the real scan: read every role's trust policy back, and flag any with an unrestricted principal:
roles anyone on earth can assume: legacy-ci-role
fixing it -- an existing role's trust policy can be replaced without recreating the role,
so nothing that already references this role's ARN has to change:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:root"
},
"Action": "sts:AssumeRole"
}
]
}
ok the scan correctly flagged the role with an unrestricted trust policy
ok the properly scoped role was correctly left unflagged
ok after the fix, the role's trust policy no longer contains a bare wildcard principal
ok the fixed trust policy now names one specific account, not the entire internet
Why this one is easy to miss. A resource policy or security group
gets reviewed because it obviously controls "access." A trust policy reads like
boilerplate -- most tutorials show Principal: * to get past a permissions
error quickly, with a comment to scope it down "later." Every scan in this lesson runs in
seconds and costs nothing to run again; the only real defense is running it, on a
schedule, rather than trusting that "later" happened.
Three different mechanisms, one common cause
- Security group: controls network reachability -- who can even open a connection.
- Bucket policy: controls data access -- who can read or write a specific object.
- Trust policy: controls identity -- who can become a role at all, upstream of whatever that role is then allowed to do.
None of the three was exploited by a novel technique. Every one was a setting that a real, direct, few-second query could have found before anyone else did -- which is the entire point of running the query, on a schedule, instead of trusting that a setting written once is still correct months later.