Cloud IAM privilege audit

Four queries across AWS, Azure, and GCP: admin-equivalent bindings to unexpected principals, wildcard or overly broad role definitions, aged or unrotated credentials, and cross-account or cross-project trust relationships that extend the blast radius beyond a single account.

AWS: admin-equivalent IAM policies on users and roles

Hypothesis: IAM users carry directly attached admin policies, or roles have been granted * actions on * resources outside of expected administrative tooling.

# users with directly attached admin-equivalent policies
aws iam list-users --query 'Users[].UserName' --output text | \
  tr '\t' '\n' | while read user; do
    policies=$(aws iam list-attached-user-policies \
      --user-name "$user" \
      --query 'AttachedPolicies[].PolicyArn' \
      --output text)
    if [ -n "$policies" ]; then
      echo "$user: $policies"
    fi
  done

# roles with wildcard action/resource in inline or attached policies
# identify inline policies first
aws iam list-roles --query 'Roles[].RoleName' --output text | \
  tr '\t' '\n' | while read role; do
    aws iam list-role-policies --role-name "$role" \
      --query 'PolicyNames[]' --output text | \
    tr '\t' '\n' | while read policy; do
      aws iam get-role-policy \
        --role-name "$role" --policy-name "$policy" \
        --query 'PolicyDocument' --output json | \
      jq -e '.Statement[] | select(
        ((.Action  | if type == "array" then .[] else . end) == "*") or
        ((.Resource | if type == "array" then .[] else . end) == "*")
      )' > /dev/null 2>&1 && \
        echo "WILDCARD inline: $role / $policy"
    done
  done

Any user with a directly attached AdministratorAccess policy warrants documentation. IAM best practice places admin permissions on roles, not users, so users with directly attached policies are a finding regardless of the specific policy name.

AWS: access keys older than 90 days

Hypothesis: long-lived programmatic credentials have not been rotated, increasing the window for undetected key exfiltration to remain exploitable.

aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | \
  base64 --decode | \
  awk -F',' 'NR==1 { for(i=1;i<=NF;i++) h[$i]=i; print; next }
  {
    user=$h["user"];
    key1=$h["access_key_1_last_rotated"];
    key2=$h["access_key_2_last_rotated"];
    active1=$h["access_key_1_active"];
    active2=$h["access_key_2_active"];
    if (active1=="true" && key1 != "N/A") print user, "key1", key1;
    if (active2=="true" && key2 != "N/A") print user, "key2", key2;
  }' | \
  awk '{
    cmd = "date -d \""$3"\" +%s 2>/dev/null || date -j -f \"%Y-%m-%dT%H:%M:%S+00:00\" \""$3"\" +%s"
    cmd | getline ts; close(cmd)
    now = systime()
    age = int((now - ts) / 86400)
    if (age > 90) printf "%-30s %-8s %s (%d days)\n", $1, $2, $3, age
  }'

Keys active for over 90 days without rotation are a finding. Keys active for over a year with no last-used date are unused and candidates for immediate deletion.

AWS: cross-account trust relationships

Hypothesis: an IAM role trusts an external AWS account or an overly broad principal, allowing any principal in that account to assume the role without further constraint.

# list all roles and their trust policy principals
aws iam list-roles | \
  jq -r '.Roles[] |
    .RoleName as $role |
    .AssumeRolePolicyDocument.Statement[]? |
    .Principal |
    (if type == "object" then (to_entries[] | .value) else . end) |
    if type == "array" then .[] else . end |
    select(
      (startswith("arn:aws:iam::") | not) and
      . != "ec2.amazonaws.com" and
      . != "lambda.amazonaws.com"
    ) |
    [$role, .] | @tsv' | \
  column -t

Principals that are not AWS service endpoints and not from the expected account ID warrant review. An * in the Principal field grants any AWS principal the ability to attempt role assumption; whether they succeed depends on the Condition block, which the query above does not evaluate.

Azure: privileged role assignments at broad scope

Hypothesis: managed identities, service principals, or guest accounts hold privileged roles at subscription or management group scope rather than the minimum necessary scope.

# role assignments at subscription scope (broad) for non-owner principals
az role assignment list --scope "/subscriptions/$(az account show --query id -o tsv)" \
  --query "[].{Principal:principalName,Role:roleDefinitionName,Type:principalType,Scope:scope}" \
  --output table

# guest accounts with any directory role in Entra ID
az ad user list --filter "userType eq 'Guest'" \
  --query "[].userPrincipalName" \
  --output tsv | while read upn; do
    roles=$(az role assignment list --assignee "$upn" \
      --query "[].roleDefinitionName" --output tsv 2>/dev/null)
    [ -n "$roles" ] && echo "$upn: $roles"
  done

# service principals with Owner or Contributor at subscription scope
az role assignment list \
  --query "[?roleDefinitionName=='Owner' || roleDefinitionName=='Contributor'].
    {Principal:principalName,Type:principalType,Scope:scope}" \
  --output table | grep -i "serviceprincipal\|application"

Service principals holding Owner or Contributor at subscription scope have write access to every resource in the subscription, including other managed identities and service principals. Each entry warrants a documented justification.

GCP: primitive roles and user-managed service account keys

Hypothesis: primitive roles (Owner, Editor, Viewer) remain in use at the project level, and service accounts carry user-managed keys that have not been rotated.

# project-level IAM bindings using primitive roles
PROJECT=$(gcloud config get-value project)
gcloud projects get-iam-policy "$PROJECT" --format=json | \
  jq -r '.bindings[] |
    select(.role | test("roles/owner|roles/editor|roles/viewer")) |
    .role as $role |
    .members[] |
    select(startswith("serviceAccount:") or startswith("user:")) |
    [$role, .] | @tsv' | \
  column -t

# service accounts with user-managed keys
gcloud iam service-accounts list --format='value(email)' | \
  while read sa; do
    keys=$(gcloud iam service-accounts keys list \
      --iam-account="$sa" \
      --managed-by=user \
      --format='value(name,validAfterTime,validBeforeTime)')
    [ -n "$keys" ] && echo "$sa: $keys"
  done

User-managed service account keys in GCP are functionally equivalent to AWS long-lived access keys: they do not rotate automatically and persist until explicitly deleted. Each user-managed key is a credential that exists outside GCP’s normal automatic rotation. The presence of keys created more than 90 days ago without a rotation record is the finding. Google-managed keys rotate automatically and need no review.