Emmanuel Akinfulubi

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.

What a wildcard actually matches, as a real pattern check

ran at build time
# 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.

Two real IAM users, two real policies, read back to compare

ran at build time
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.

A credential's age: a real, queryable fact, not a guess

ran at build time
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 Resource a 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.

Deleted in the next commit. Still in history.

ran at build time
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.

A real secrets store: rotated by name, never edited by hand

ran at build time
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.

A secret found by its shape, not by understanding the code around it

ran at build time
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.

A security group scan: found by the exact query, not by reading rules

ran at build time
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.

An S3 bucket exposed by its policy, found the same way a real scanner finds it

ran at build time
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.

A role's trust policy: who can BECOME it, scanned and fixed

ran at build time
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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.