Cloud Security · lesson 7 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
Also part of: AWS
Auditing the AWS IAM Credential Report with Python
Parse an AWS IAM credential report with Python's csv module: find active root keys, users without MFA, keys over 90 days old and unused credentials.
By SkillByExample · Published
The IAM credential report is the quickest account-wide view of who can log in and with what. It
is a CSV with one row per IAM user plus the root user, and AWS generates it on request. Python’s
csv module and a fixed “today” turn it into a repeatable audit for root keys, missing MFA, old
keys and credentials nobody uses.
Tested on 2026-10-15 with Python 3.12.15 and the standard library only. The report below is a
sample written for this lesson, not a real account. It uses the documented column layout and
value formats, and 111122223333, the example account ID from AWS’s own documentation. Save it as
credential-report.csv:
user,arn,user_creation_time,password_enabled,password_last_used,password_last_changed,password_next_rotation,mfa_active,access_key_1_active,access_key_1_last_rotated,access_key_1_last_used_date,access_key_1_last_used_region,access_key_1_last_used_service,access_key_2_active,access_key_2_last_rotated,access_key_2_last_used_date,access_key_2_last_used_region,access_key_2_last_used_service,cert_1_active,cert_1_last_rotated,cert_2_active,cert_2_last_rotated
<root_account>,arn:aws:iam::111122223333:root,2021-04-02T10:15:00+00:00,not_supported,2026-09-30T08:12:44+00:00,not_supported,not_supported,false,true,2023-02-11T09:00:00+00:00,2026-10-14T22:41:00+00:00,us-east-1,s3,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
alice,arn:aws:iam::111122223333:user/alice,2022-01-10T09:00:00+00:00,true,2026-10-14T17:05:31+00:00,2026-07-01T09:30:00+00:00,N/A,true,true,2026-08-01T12:00:00+00:00,2026-10-14T06:20:00+00:00,eu-west-1,ec2,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
bob,arn:aws:iam::111122223333:user/bob,2023-05-22T14:30:00+00:00,true,2026-10-10T11:47:02+00:00,2026-05-22T14:31:00+00:00,N/A,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
ci-deploy,arn:aws:iam::111122223333:user/ci-deploy,2024-02-01T08:00:00+00:00,false,N/A,N/A,N/A,false,true,2025-11-20T08:00:00+00:00,2026-10-14T23:59:10+00:00,us-east-1,lambda,true,2026-09-01T08:00:00+00:00,N/A,N/A,N/A,false,N/A,false,N/A
carol,arn:aws:iam::111122223333:user/carol,2024-09-03T10:00:00+00:00,true,no_information,2024-09-03T10:00:00+00:00,N/A,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
dave,arn:aws:iam::111122223333:user/dave,2022-06-15T09:00:00+00:00,true,2026-03-02T16:20:00+00:00,2025-12-01T09:00:00+00:00,N/A,true,true,2025-01-15T09:00:00+00:00,2026-02-27T13:10:00+00:00,us-east-1,s3,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
In a real account, aws iam generate-credential-report builds the report, and
aws iam get-credential-report --query Content --output text | base64 -d > credential-report.csv
downloads it. The API returns the CSV base64-encoded.
Read the report
csv.DictReader turns each row into a dict keyed by the header, so columns are read by name and
not by position:
# peek.py: one line per identity in an IAM credential report
import csv
import sys
with open(sys.argv[1], newline="") as f:
rows = list(csv.DictReader(f))
print(f"{len(rows)} rows, {len(rows[0])} columns")
for row in rows:
print(f"{row['user']:15} password={row['password_enabled']:13} "
f"mfa={row['mfa_active']:5} key1={row['access_key_1_active']:5} "
f"key2={row['access_key_2_active']}")
$ python peek.py credential-report.csv
6 rows, 22 columns
<root_account> password=not_supported mfa=false key1=true key2=false
alice password=true mfa=true key1=true key2=false
bob password=true mfa=false key1=false key2=false
ci-deploy password=false mfa=false key1=true key2=true
carol password=true mfa=false key1=false key2=false
dave password=true mfa=true key1=true key2=false
Every value is a string, including "true" and "false", so compare with == "true", never as a
bool, because bool("false") is True. The root user is a row like any other, but its
password_enabled is not_supported, not true. That will matter later.
Two ways the date maths breaks
Access-key age is “today minus access_key_1_last_rotated”. “Today” comes from the command line,
so the audit gives the same answer whenever it runs:
# key_age.py: how old is each user's first access key?
import csv
import sys
from datetime import datetime
today = datetime.fromisoformat(sys.argv[2]) # pinned, e.g. 2026-10-15
with open(sys.argv[1], newline="") as f:
for row in csv.DictReader(f):
rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
print(row["user"], (today - rotated).days, "days")
$ python key_age.py credential-report.csv 2026-10-15
Traceback (most recent call last):
File "/lab/key_age.py", line 11, in <module>
print(row["user"], (today - rotated).days, "days")
~~~~~~^~~~~~~~~
TypeError: can't subtract offset-naive and offset-aware datetimes
The report’s timestamps end in +00:00, so they parse to timezone-aware datetimes. 2026-10-15
has no offset, so it parses to a naive one, and Python refuses to guess how they relate. Give
“today” an explicit UTC timezone:
# key_age.py: how old is each user's first access key? (today in UTC)
import csv
import sys
from datetime import datetime, timezone
today = datetime.fromisoformat(sys.argv[2]).replace(tzinfo=timezone.utc)
with open(sys.argv[1], newline="") as f:
for row in csv.DictReader(f):
rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
print(row["user"], (today - rotated).days, "days")
$ python key_age.py credential-report.csv 2026-10-15
<root_account> 1341 days
alice 74 days
Traceback (most recent call last):
File "/lab/key_age.py", line 10, in <module>
rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: Invalid isoformat string: 'N/A'
Two rows work, then bob has no access key, and the report says so with N/A where a timestamp
would be. The documented placeholders are N/A (no such credential, or never used),
no_information (a password never used) and not_supported (root’s password fields). None of
them is a date, and each means something different from “old”. A small module handles both
problems once:
# report.py: load an IAM credential report and turn its timestamps into ages
import csv
from datetime import datetime, timezone
NO_DATE = {"N/A", "no_information", "not_supported"}
def load(path):
with open(path, newline="") as f:
return list(csv.DictReader(f))
def parse_today(text):
"""'2026-10-15' -> midnight UTC, comparable with the report's timestamps."""
return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
def days_since(value, today):
"""Whole days from a report timestamp to today, or None when there is no date."""
if value in NO_DATE:
return None
return (today - datetime.fromisoformat(value)).days
days_since returns None for “no date”, so each check decides what a missing date means. An
active key with no last-used date has never been used. That is a finding, not a crash.
Audit the access keys
Three rules from the usual IAM baselines: no access keys on the root user, rotate keys within 90 days, and remove keys that aren’t used:
# audit_keys.py: root keys, old keys and unused keys in a credential report
import sys
from report import days_since, load, parse_today
MAX_DAYS = 90
today = parse_today(sys.argv[2])
for row in load(sys.argv[1]):
user = row["user"]
for n in (1, 2):
if row[f"access_key_{n}_active"] != "true":
continue
if user == "<root_account>":
print(f"CRITICAL {user}: access key {n} is active")
age = days_since(row[f"access_key_{n}_last_rotated"], today)
if age > MAX_DAYS:
print(f"HIGH {user}: access key {n} is {age} days old")
idle = days_since(row[f"access_key_{n}_last_used_date"], today)
if idle is None:
print(f"MEDIUM {user}: access key {n} has never been used")
elif idle > MAX_DAYS:
print(f"MEDIUM {user}: access key {n} unused for {idle} days")
$ python audit_keys.py credential-report.csv 2026-10-15
CRITICAL <root_account>: access key 1 is active
HIGH <root_account>: access key 1 is 1341 days old
HIGH ci-deploy: access key 1 is 328 days old
MEDIUM ci-deploy: access key 2 has never been used
HIGH dave: access key 1 is 637 days old
MEDIUM dave: access key 1 unused for 229 days
The root key is the headline: it can do anything in the account, IAM policies can’t limit it, and
the report shows it was used yesterday from us-east-1. ci-deploy shows a rotation that was
never finished. A second key was created six weeks ago but never used, while the 328-day-old key is
still doing the work. dave hasn’t used his key since February, which usually means someone left.
Inactive keys are skipped, because a disabled key can’t be used. age is never None for an
active key, because every active key has a rotation date.
Audit console access
The console checks are MFA on every password user, and passwords that are never used:
# audit_console.py: console passwords without MFA, and passwords nobody uses
import sys
from report import days_since, load, parse_today
MAX_DAYS = 90
today = parse_today(sys.argv[2])
for row in load(sys.argv[1]):
user = row["user"]
if row["password_enabled"] != "true":
continue
if row["mfa_active"] != "true":
print(f"HIGH {user}: console password without MFA")
idle = days_since(row["password_last_used"], today)
if idle is None:
print(f"MEDIUM {user}: password never used")
elif idle > MAX_DAYS:
print(f"MEDIUM {user}: password unused for {idle} days")
$ python audit_console.py credential-report.csv 2026-10-15
HIGH bob: console password without MFA
HIGH carol: console password without MFA
MEDIUM carol: password never used
MEDIUM dave: password unused for 226 days
carol’s no_information became “never used”: an account created two years ago, never logged into
and with no MFA, so anyone who learns the initial password gets a fresh, unmonitored login. dave
shows up again, now through his password. Look at what’s missing, though. The root user has
mfa_active=false and doesn’t appear, because its password_enabled is not_supported, so the
first if skipped it. The first practice task fixes that.
Practice
- Make
audit_console.pyreport the root user’s missing MFA, as CRITICAL.
Solution
# audit_console.py: console passwords without MFA (root included), and unused passwords
import sys
from report import days_since, load, parse_today
MAX_DAYS = 90
today = parse_today(sys.argv[2])
for row in load(sys.argv[1]):
user = row["user"]
if user == "<root_account>":
if row["mfa_active"] != "true":
print(f"CRITICAL {user}: no MFA on the root user")
continue
if row["password_enabled"] != "true":
continue
if row["mfa_active"] != "true":
print(f"HIGH {user}: console password without MFA")
idle = days_since(row["password_last_used"], today)
if idle is None:
print(f"MEDIUM {user}: password never used")
elif idle > MAX_DAYS:
print(f"MEDIUM {user}: password unused for {idle} days")
$ python audit_console.py credential-report.csv 2026-10-15
CRITICAL <root_account>: no MFA on the root user
HIGH bob: console password without MFA
HIGH carol: console password without MFA
MEDIUM carol: password never used
MEDIUM dave: password unused for 226 days
The root user always has a password, so its row is handled before the password_enabled test.
A check that filters on a column value can silently skip the row that matters most. Test each
check against a row you expect it to flag.
- Write
stale_users.py: list identities where every enabled credential (password and active keys) is unused for more than 90 days or has never been used. These are the deprovisioning candidates.
Solution
# stale_users.py: identities whose every enabled credential is idle
import sys
from report import days_since, load, parse_today
MAX_DAYS = 90
today = parse_today(sys.argv[2])
for row in load(sys.argv[1]):
last_used = []
if row["password_enabled"] == "true":
last_used.append(row["password_last_used"])
for n in (1, 2):
if row[f"access_key_{n}_active"] == "true":
last_used.append(row[f"access_key_{n}_last_used_date"])
if not last_used:
continue
idle = [days_since(value, today) for value in last_used]
if all(days is None or days > MAX_DAYS for days in idle):
print(f"{row['user']}: idle {idle}")
$ python stale_users.py credential-report.csv 2026-10-15
carol: idle [None]
dave: idle [226, 229]
dave is idle on both his password and his key, and carol has never used her only credential.
ci-deploy isn’t listed, even with a never-used key, because its other key was used yesterday.
The per-credential checks above already cover that key.
- Run
audit_keys.pywith today set to2027-01-01. Which findings are new, and why is pinning today the right default for an audit script?
Solution
$ python audit_keys.py credential-report.csv 2027-01-01
CRITICAL <root_account>: access key 1 is active
HIGH <root_account>: access key 1 is 1419 days old
HIGH alice: access key 1 is 152 days old
HIGH ci-deploy: access key 1 is 406 days old
HIGH ci-deploy: access key 2 is 121 days old
MEDIUM ci-deploy: access key 2 has never been used
HIGH dave: access key 1 is 715 days old
MEDIUM dave: access key 1 unused for 307 days
alice and ci-deploy’s second key crossed 90 days. The data didn’t change, only the date did.
A report is a snapshot, so the audit should be evaluated as of the time it was generated. With
“today” as an argument, the same input always gives the same findings, and a test can pin the
date. In production, pass the date of the report’s generation.