Python Compliance and Audit Scripts
Automate IAM compliance checks and CloudTrail audits with Python.
Introduction
30 Second Summary
Every organization has compliance checks that someone does by hand. Scrolling through IAM consoles, verifying MFA status user by user, pulling CloudTrail logs to prove a contractor's access was removed.
In this project, you will build Python scripts that automate these exact tasks against your own AWS account. You will scan all IAM users for security gaps, generate audit evidence for any user's API activity, and produce structured reports ready for an auditor.
What You'll Build
You will run a compliance checker from your terminal that instantly flags security gaps across every user in your account, then generate formatted audit evidence for any user with a single command.
By the end of this project, you'll have:
- A working IAM compliance checker that scans all users and prints which ones lack MFA, have stale access keys, or have gone inactive. The results are saved as an audit-ready JSON report you could hand directly to an auditor.
- A CloudTrail audit evidence generator that takes a username and date range, then produces formatted proof of every API call that user made. This is suitable as SOX compliance evidence for contractor offboarding verification.
- A professional GitHub repository (aws-automation-suite) with a clean folder skeleton ready for Lambda, bash, Ansible, and Terraform components in follow-on projects.
- Secret Mission: Build an EC2 cost optimization script that scans all regions for stopped instances, missing cost allocation tags, and estimates monthly savings.
Are there any prerequisites?
This project assumes you have Python 3.10+ installed, the AWS CLI configured with credentials, VS Code installed, and a GitHub account. If you do not have these yet, Step 1 will guide you through verifying each one.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This project turns manual compliance and audit work into automated Python scripts that run in seconds and produce reports you could hand directly to an auditor.
Set Up the Repository and Python Environment
A professional automation suite needs a clear structure from the start. If you build scripts first and organize later, you end up moving files, fixing imports, and rewriting paths across five different components.
In this step, you will create the full aws-automation-suite repository skeleton on GitHub, set up a Python virtual environment with boto3, and verify your AWS credentials are working.
In this step, get ready to:
- Create the aws-automation-suite GitHub repository with the full folder structure.
- Set up a Python virtual environment and install boto3.
- Verify AWS connectivity with a boto3 call.
Create the repository and folder structure
You need a Git repository to version-control your work and a folder for each future component of the suite.
- Open GitHub in your browser and sign in.
- Click the + icon in the top-right corner, then select New repository.
- Enter aws-automation-suite as the repository name.
- Leave Add a README file unchecked (you will create your own).
- Click Create repository.
GitHub shows you the new empty repository page with a Quick setup section containing your repository URL.
- Copy the HTTPS URL shown on the page (it looks like https://github.com/your-username/aws-automation-suite.git).
- Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal, and press Enter.
- Clone the repository and create the folder structure by running these commands:
cd ~/Desktop
git clone [[REPO_URL="https://github.com/your-username/aws-automation-suite.git"]]
cd aws-automation-suite
mkdir python lambda bash ansible terraform
- Confirm the folders exist by running:
ls
You should see five folders listed: ansible, bash, lambda, python, and terraform.
- Create the README files for each component by running these commands:
printf "# aws-automation-suite\nPortfolio narrative and project overview for the full 5-component AWS automation suite.\n" > README.md
printf "# Lambda Component\nPlaceholder for future Lambda automation scripts.\n" > lambda/README.md
printf "# Bash Component\nPlaceholder for future Bash automation scripts.\n" > bash/README.md
printf "# Ansible Component\nPlaceholder for future Ansible automation scripts.\n" > ansible/README.md
printf "# Terraform Component\nPlaceholder for future Terraform automation scripts.\n" > terraform/README.md
What do these files establish?
The root README.md gives anyone visiting the repo an overview of the full five-component suite.
Each subfolder's README.md is a placeholder that reserves space for future automation components (Lambda, Bash, Ansible, Terraform). It also ensures Git tracks each directory since Git ignores empty folders.
- Push the initial structure to GitHub by running these commands:
git add .
git commit -m "Initial repo structure with component folders"
git push origin main
- Switch back to your browser and refresh the GitHub repository page.
You should see all five folders and the root README.md displayed on the repository's main page.
Push failed or permission denied?
- If you see fatal: 'origin' does not appear to be a git repository, confirm you cloned the repo (not just created a folder with mkdir).
- If GitHub asks for authentication, follow the prompt to sign in. GitHub requires a personal access token or SSH key for push operations.
- If the branch is called master instead of main, run git branch -M main before pushing.
help me push my initial commit to GitHub
- Switch back to VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the aws-automation-suite folder, then click Open.
You should see the five component folders in VS Code's Explorer sidebar.
Set up the Python virtual environment
The python/ folder is where you will write all the automation scripts in this project. A virtual environment keeps your dependencies isolated from other Python projects on your machine.
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Navigate into the python directory and create a virtual environment by running:
cd python
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to create a new virtual environment in a folder called venv. This folder contains its own copy of the Python interpreter and a space for packages that won't interfere with your system Python.
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install boto3 (the AWS SDK for Python) by running:
pip install boto3==1.43.46
You should see pip download and install boto3 along with its dependencies, ending with a Successfully installed message.
- Create the requirements.txt file to pin the dependency version by running:
printf "boto3==1.43.46\n" > requirements.txt
- Verify the file was created correctly by running:
cat requirements.txt
You should see boto3==1.43.46 printed to the terminal.
Pip install failed?
- If you see command not found: pip, make sure your virtual environment is activated. You should see (venv) in your prompt.
- If the install hangs or times out, check your internet connection and try again.
help me install boto3 in my virtual environment
Verify AWS connectivity
Before writing any automation scripts, you need to confirm that boto3 can reach AWS using your configured credentials. This quick check proves your environment is ready.
Before you run the next command, what do you think it will print if your AWS credentials are configured correctly?
- Test AWS connectivity by running:
python -c "import boto3; print(boto3.client('sts').get_caller_identity()['Account'])"
You should see your 12-digit AWS account ID printed to the terminal. This confirms boto3 can authenticate with AWS using your CLI credentials.
What does this command do?
boto3.client('sts').get_caller_identity() calls the AWS Security Token Service to ask "who am I?" It returns your account ID, user ARN, and user ID.
If this works, every other AWS API call in this project will work too, because they all use the same credentials.
Seeing a credentials error?
- If you see NoCredentialsError, your AWS CLI is not configured. Run aws configure in your terminal and enter your access key ID, secret access key, and default region.
- If you see InvalidClientTokenId, your access key may be deactivated or deleted. Check the IAM console in AWS to verify your key is active.
- Make sure your virtual environment is still active ((venv) in the prompt). If not, reactivate it before retrying.
help me fix my AWS credentials for boto3
Your repository is live on GitHub, your Python environment is isolated and ready, and AWS responds to your calls. Next up, you will write your first automation script that pulls real IAM user data from your account.
Build a Basic IAM User Scanner
Before you can check compliance posture, you need to prove you can pull real user data from the IAM API.
In this step, you will write a Python script that connects to your AWS account and lists every IAM user. This gives you the building block that the full compliance checker will expand on in the next step.
In this step, get ready to:
- Create iam_scanner_basic.py with a function that queries the IAM API.
- Handle pagination so the script works regardless of how many users exist.
- Run the script and see your IAM users listed in the terminal.
Create the user scanning function
The IAM API can return users in batches (pages) when an account has many users. Using a paginator ensures your script collects every user, not just the first page.
- In VS Code, create a new file called iam_scanner_basic.py inside the python/ folder.
- Add the following code to define the scan function:
"""
Basic IAM User Scanner
Enumerates all IAM users in the AWS account.
This is the foundation - the compliance checker builds on this.
"""
import boto3
from datetime import datetime, timezone
def scan_iam_users():
"""List all IAM users with basic info."""
iam = boto3.client('iam')
paginator = iam.get_paginator('list_users')
users = []
for page in paginator.paginate():
for user in page['Users']:
users.append({
'UserName': user['UserName'],
'CreateDate': user['CreateDate'].strftime('%Y-%m-%d'),
'PasswordLastUsed': user.get('PasswordLastUsed', 'Never').strftime('%Y-%m-%d') if user.get('PasswordLastUsed') else 'Never'
})
return users
What does this code do?
boto3.client('iam') creates a connection to the AWS IAM service using the credentials your CLI is already configured with.
The get_paginator('list_users') pattern automatically handles the case where AWS returns users in multiple batches. Without it, you would only get the first 100 users.
For each user, the function extracts their username, account creation date, and when they last logged in via password. If they have never logged in, it records 'Never'.
Add the display logic
The scan function collects data, but you need a main() function to format it as a readable table in the terminal.
- Add the following code below the scan_iam_users function in iam_scanner_basic.py:
def main():
print("=" * 60)
print("IAM USER SCANNER - Basic Enumeration")
print("=" * 60)
print()
users = scan_iam_users()
if not users:
print("No IAM users found in this account.")
return
print(f"Found {len(users)} IAM user(s):\n")
print(f"{'Username':<30} {'Created':<12} {'Last Login':<12}")
print("-" * 54)
for user in users:
print(f"{user['UserName']:<30} {user['CreateDate']:<12} {user['PasswordLastUsed']:<12}")
print()
print("NOTE: This raw listing does not show compliance status.")
print("Run iam_compliance_checker.py for full compliance analysis.")
if __name__ == '__main__':
main()
What does this code do?
main() calls scan_iam_users() and prints the results as an aligned table with columns for username, creation date, and last login.
The if __name__ == '__main__' guard at the bottom means this display logic only runs when you execute the file directly. Other scripts can import scan_iam_users without triggering the print output.
- Save iam_scanner_basic.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before running it.
ⓧ I'd like to double check the full code
Your iam_scanner_basic.py should contain these two sections in order:
- The docstring, imports, and scan_iam_users() function (first chunk above).
- The main() function and if __name__ == '__main__' block (second chunk above).
Scroll up and confirm both sections are present, in that order.
Run the scanner
Before you run this, think about what you expect to see. How many IAM users does your AWS account have?
- In your terminal, make sure you are inside the python/ directory with your virtual environment active.
- Run the scanner by executing:
python iam_scanner_basic.py
You should see a table listing your IAM users with their creation dates and last login dates. If you have only one user (yourself), you will see one entry.
What should I see?
The output starts with a banner (IAM USER SCANNER - Basic Enumeration), followed by a count of users and a formatted table. Each row shows a username, the date that user was created, and when they last logged in.
If you see Never in the Last Login column, that user has never signed in via the AWS console (programmatic-only users show this).
Seeing an error?
- NoCredentialsError means your AWS CLI credentials are not configured or the virtual environment cannot find them. Run aws sts get-caller-identity to confirm your CLI works.
- ModuleNotFoundError: No module named 'boto3' means your virtual environment is not active or boto3 is not installed in it. Activate your venv and run pip install boto3 again.
- AccessDenied means your IAM user does not have permission to list users. Confirm your CLI user has the IAMReadOnlyAccess policy or equivalent attached.
Still stuck? Help me debug why my IAM scanner script is not running.
Your scanner works. You can now pull live IAM user data from your AWS account with a single command. Next up, you will turn this raw listing into a full compliance checker that flags security risks.
Build the Full IAM Compliance Checker
Your basic scanner lists IAM users, but that raw data is not something you can hand to an auditor and say "we are compliant." An auditor needs answers to specific questions: does every user have MFA enabled? Are access keys being rotated? Are there dormant accounts?
In this step, you will build the production compliance checker that pulls the IAM credential report, runs three automated checks against every user, and generates structured findings you could submit in a real audit.
In this step, get ready to:
- Retrieve and parse the IAM credential report CSV using boto3.
- Implement three compliance checks: missing MFA, stale access keys, and inactive accounts.
- Generate a JSON report and a human-readable terminal summary with per-user findings.
Retrieve and parse the IAM credential report
AWS provides a single API call that generates a CSV containing MFA status, key rotation dates, and last-used dates for every IAM user. This is far more efficient than calling multiple APIs per user.
- Create a new file called iam_compliance_checker.py inside your python/ directory.
- Add the module header with imports and configuration constants by pasting this code:
"""\nIAM Compliance Checker\nScans all IAM users and identifies:\n- Users without MFA enabled\n- Access keys older than 90 days\n- Users inactive for 30+ days\n\nGenerates JSON report and human-readable summary.\nReplaces manual MFA compliance checking done at USPS.\n"""\n\nimport boto3\nimport csv\nimport io\nimport json\nimport os\nimport time\nfrom datetime import datetime, timezone, timedelta\n\n\nREPORTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'reports')\nMAX_KEY_AGE_DAYS = 90\nMAX_INACTIVE_DAYS = 30
What does this code do?
REPORTS_DIR dynamically resolves to a reports/ folder next to the script, regardless of where you run it from.
MAX_KEY_AGE_DAYS (90) and MAX_INACTIVE_DAYS (30) are your compliance thresholds. Keys older than 90 days and accounts inactive for 30+ days get flagged.
- Below the constants, add the two functions that retrieve and parse the credential report:
\n\ndef generate_credential_report(iam_client):\n """Generate and retrieve the IAM credential report."""\n iam_client.generate_credential_report()\n\n for attempt in range(10):\n time.sleep(2)\n try:\n response = iam_client.get_credential_report()\n if response['ReportFormat'] == 'text/csv':\n return response['Content'].decode('utf-8')\n except iam_client.exceptions.CredentialReportNotReadyException:\n continue\n\n raise TimeoutError("Credential report not ready after 20 seconds.")\n\n\ndef parse_credential_report(csv_content):\n """Parse the credential report CSV into a list of dicts."""\n reader = csv.DictReader(io.StringIO(csv_content))\n return list(reader)
Why does the script poll in a loop?
generate_credential_report() triggers report generation asynchronously. The report takes a few seconds to compile, so the script calls get_credential_report() in a retry loop, sleeping 2 seconds between attempts.
If the report is not ready, AWS raises CredentialReportNotReadyException. The script catches that and retries up to 10 times.
- Save iam_compliance_checker.py.
- Verify the credential report retrieval works by running this command in your terminal:
python -c "from iam_compliance_checker import generate_credential_report, parse_credential_report; import boto3; iam = boto3.client('iam'); users = parse_credential_report(generate_credential_report(iam)); print(f'Credential report contains {len(users)} user(s)')"
You should see output like Credential report contains 1 user(s) (or more, depending on how many IAM users exist in your account).
Seeing a CredentialReportNotReadyException or timeout?
This usually means the report is taking longer than expected. Run the command again. AWS sometimes needs a moment on first generation.
If you see NoCredentialsError, your virtual environment may not have access to your AWS credentials. Make sure your AWS CLI is configured and you activated your virtual environment.
help me debug my credential report retrieval error
Implement the three compliance checks
Each check function examines one aspect of the credential report CSV row for a single user. Together they cover the three most common audit findings: missing MFA, stale keys, and dormant accounts.
- Below parse_credential_report, add the MFA check and key age check functions:
\n\ndef check_mfa_compliance(user_row):\n """Check if user has MFA enabled."""\n return user_row.get('mfa_active', 'false').lower() == 'true'\n\n\ndef check_key_age(user_row, now):\n """Check if access keys are older than MAX_KEY_AGE_DAYS."""\n findings = []\n\n for key_num in ['1', '2']:\n active_field = f'access_key_{key_num}_active'\n rotated_field = f'access_key_{key_num}_last_rotated'\n\n if user_row.get(active_field, 'false').lower() == 'true':\n rotated_date_str = user_row.get(rotated_field, 'N/A')\n if rotated_date_str not in ('N/A', 'not_supported', ''):\n try:\n rotated_date = datetime.fromisoformat(rotated_date_str.replace('+00:00', '+00:00'))\n if rotated_date.tzinfo is None:\n rotated_date = rotated_date.replace(tzinfo=timezone.utc)\n age_days = (now - rotated_date).days\n if age_days > MAX_KEY_AGE_DAYS:\n findings.append({\n 'key_number': key_num,\n 'age_days': age_days,\n 'last_rotated': rotated_date_str\n })\n except (ValueError, TypeError):\n pass\n\n return findings
What do these checks do?
check_mfa_compliance() reads the mfa_active column from the credential report CSV. If it is not TRUE, the user lacks MFA.
check_key_age() loops through both access key slots (AWS allows up to 2 per user). For each active key, it calculates how many days since the key was created or last rotated. Keys older than 90 days get flagged.
- Below check_key_age, add the inactivity check function:
\n\ndef check_inactivity(user_row, now):\n """Check if user has been inactive for MAX_INACTIVE_DAYS."""\n last_activity = None\n\n password_last_used = user_row.get('password_last_used', 'N/A')\n if password_last_used not in ('N/A', 'not_supported', 'no_information', ''):\n try:\n pwd_date = datetime.fromisoformat(password_last_used.replace('+00:00', '+00:00'))\n if pwd_date.tzinfo is None:\n pwd_date = pwd_date.replace(tzinfo=timezone.utc)\n last_activity = pwd_date\n except (ValueError, TypeError):\n pass\n\n key_last_used = user_row.get('access_key_1_last_used_date', 'N/A')\n if key_last_used not in ('N/A', 'not_supported', 'no_information', ''):\n try:\n key_date = datetime.fromisoformat(key_last_used.replace('+00:00', '+00:00'))\n if key_date.tzinfo is None:\n key_date = key_date.replace(tzinfo=timezone.utc)\n if last_activity is None or key_date > last_activity:\n last_activity = key_date\n except (ValueError, TypeError):\n pass\n\n if last_activity is None:\n return {'inactive': True, 'days_inactive': 'unknown', 'last_activity': 'never'}\n\n days_inactive = (now - last_activity).days\n if days_inactive > MAX_INACTIVE_DAYS:\n return {'inactive': True, 'days_inactive': days_inactive, 'last_activity': last_activity.strftime('%Y-%m-%d')}\n\n return None
How does the inactivity check work?
This function checks two sources of activity: password_last_used (console logins) and access_key_1_last_used_date (API calls). It takes whichever is more recent as the user's last activity.
If neither field has a valid date, the user is flagged as inactive with "unknown" days. If the most recent activity was over 30 days ago, the user gets flagged with the exact count.
- Save iam_compliance_checker.py.
- Verify the check functions load correctly by running:
python -c "from iam_compliance_checker import check_mfa_compliance, check_key_age, check_inactivity; print('All check functions imported successfully')"
You should see All check functions imported successfully printed with no errors.
Seeing a SyntaxError or IndentationError?
Make sure each function is indented consistently with 4 spaces (not tabs). Check that the try/except blocks inside check_key_age and check_inactivity are properly nested.
help me fix a syntax error in my compliance checker
Wire everything together and generate reports
Now you need the orchestrator function that calls each check for every user and builds the structured findings report. Then you will add the output functions that write JSON and print a summary.
- Below check_inactivity, add the first part of the run_compliance_check function that sets up the report structure:
\n\ndef run_compliance_check():\n """Run the full compliance check and return structured report."""\n iam = boto3.client('iam')\n now = datetime.now(timezone.utc)\n\n print("Generating credential report...")\n csv_content = generate_credential_report(iam)\n users = parse_credential_report(csv_content)\n\n report = {\n 'report_generated': now.strftime('%Y-%m-%d %H:%M:%S UTC'),\n 'thresholds': {\n 'max_key_age_days': MAX_KEY_AGE_DAYS,\n 'max_inactive_days': MAX_INACTIVE_DAYS\n },\n 'summary': {\n 'total_users': 0,\n 'users_without_mfa': 0,\n 'users_with_old_keys': 0,\n 'users_inactive': 0\n },\n 'findings': []\n }
What does this set up?
The function creates an IAM client, retrieves the credential report CSV, and initializes the report dictionary. The report structure includes thresholds (so anyone reading the JSON knows what rules were applied), summary counters, and an empty findings list.
- Directly below the report dictionary (still inside run_compliance_check), add the loop that runs MFA and key age checks on each user:
\n for user_row in users:\n username = user_row.get('user', '<root_account>')\n\n if username == '<root_account>':\n continue\n\n report['summary']['total_users'] += 1\n user_findings = {'username': username, 'issues': []}\n\n if not check_mfa_compliance(user_row):\n user_findings['issues'].append({\n 'type': 'NO_MFA',\n 'severity': 'HIGH',\n 'description': 'MFA is not enabled for this user'\n })\n report['summary']['users_without_mfa'] += 1\n\n old_keys = check_key_age(user_row, now)\n if old_keys:\n for key_finding in old_keys:\n user_findings['issues'].append({\n 'type': 'OLD_ACCESS_KEY',\n 'severity': 'MEDIUM',\n 'description': f"Access key {key_finding['key_number']} is {key_finding['age_days']} days old (threshold: {MAX_KEY_AGE_DAYS})",\n 'details': key_finding\n })\n report['summary']['users_with_old_keys'] += 1
What is happening in this loop?
The loop iterates every user in the credential report, skipping the root account. For each user, it runs the MFA check (severity HIGH) and the key age check (severity MEDIUM), appending any issues to that user's findings.
Each finding includes a type, severity, and human-readable description. This structure makes the JSON report easy to filter by severity later.
- Save iam_compliance_checker.py and verify there are no syntax errors by running:
python -c "import iam_compliance_checker; print('No syntax errors')"
You should see No syntax errors with no traceback.
- Continue inside the same for loop. Directly below the key age check block, add the inactivity check and the findings collection logic that completes the function:
\n inactivity = check_inactivity(user_row, now)\n if inactivity:\n user_findings['issues'].append({\n 'type': 'INACTIVE_USER',\n 'severity': 'LOW',\n 'description': f"User inactive for {inactivity['days_inactive']} days (threshold: {MAX_INACTIVE_DAYS})",\n 'details': inactivity\n })\n report['summary']['users_inactive'] += 1\n\n if user_findings['issues']:\n report['findings'].append(user_findings)\n\n return report
What does this complete?
The inactivity check (severity LOW) is the third and final compliance rule. After all three checks run for a user, only users with at least one issue get added to the findings list. The function then returns the full report dictionary.
- Below run_compliance_check, add the print_summary function that formats findings for the terminal:
\n\ndef print_summary(report):\n """Print human-readable compliance summary."""\n summary = report['summary']\n\n print()\n print("=" * 60)\n print("IAM COMPLIANCE REPORT")\n print("=" * 60)\n print(f"Generated: {report['report_generated']}")\n print(f"Thresholds: Key age > {report['thresholds']['max_key_age_days']} days, "\n f"Inactive > {report['thresholds']['max_inactive_days']} days")\n print()\n print(f"Total Users Scanned: {summary['total_users']}")\n print(f"Users Without MFA: {summary['users_without_mfa']}")\n print(f"Users With Old Keys: {summary['users_with_old_keys']}")\n print(f"Inactive Users: {summary['users_inactive']}")\n print()\n\n if report['findings']:\n print("-" * 60)\n print("FINDINGS BY USER:")\n print("-" * 60)\n for user_finding in report['findings']:\n print(f"\\n {user_finding['username']}:")\n for issue in user_finding['issues']:\n severity_marker = {'HIGH': '[!]', 'MEDIUM': '[~]', 'LOW': '[-]'}\n marker = severity_marker.get(issue['severity'], '[ ]')\n print(f" {marker} {issue['description']}")\n else:\n print("No compliance issues found. All users are compliant.")\n\n print()\n print("=" * 60)
What do the severity markers mean?
[!] marks HIGH severity (missing MFA). [~] marks MEDIUM (old access keys). [-] marks LOW (inactive accounts). This gives the reader an instant visual priority order when scanning the terminal output.
- Finally, below print_summary, add the save_report function and the main entry point:
\n\ndef save_report(report):\n """Save report as JSON file."""\n os.makedirs(REPORTS_DIR, exist_ok=True)\n filepath = os.path.join(REPORTS_DIR, 'iam_compliance_report.json')\n with open(filepath, 'w') as f:\n json.dump(report, f, indent=2)\n return filepath\n\n\ndef main():\n report = run_compliance_check()\n filepath = save_report(report)\n print_summary(report)\n print(f"Full report saved to: {filepath}")\n\n\nif __name__ == '__main__':\n main()
What does save_report do?
save_report() creates the reports/ directory if it does not exist, then writes the full structured report as indented JSON. The main() function orchestrates everything: run the check, save the file, then print the summary.
- Save iam_compliance_checker.py.
Before you run the full compliance checker, what do you expect to see? Think about your own IAM account. Do you have MFA enabled? How old is your access key?
- Run the full compliance checker:
python iam_compliance_checker.py
You should see the compliance report printed to your terminal with finding counts and a per-user breakdown. Your own user account will likely show at least one finding (such as an access key older than 90 days, or missing MFA).
- Verify the JSON report was created by running:
ls reports/
You should see iam_compliance_report.json listed in the output.
Script hangs or times out?
The credential report generation can take up to 20 seconds on the first run. If it times out, run the script again. AWS caches the report after the first generation.
If you see AccessDenied, your IAM user may lack the iam:GenerateCredentialReport or iam:GetCredentialReport permissions. Ensure your CLI user has these permissions (they are included in the IAMReadOnlyAccess managed policy).
help me debug my IAM compliance checker script
✔️ Awesome, I've got everything!
Great. Double check you have saved iam_compliance_checker.py and that reports/iam_compliance_report.json exists in your python/ directory.
ⓧ I'd like to double check the full code
Your iam_compliance_checker.py file should contain these sections in order. Scroll through your file and confirm each is present:
- Module docstring, imports (boto3, csv, io, json, os, time, datetime), and three constants (REPORTS_DIR, MAX_KEY_AGE_DAYS, MAX_INACTIVE_DAYS).
- generate_credential_report() with the polling loop and parse_credential_report() with csv.DictReader.
- check_mfa_compliance() returning a boolean.
- check_key_age() looping through keys 1 and 2.
- check_inactivity() comparing password_last_used and access_key_1_last_used_date.
- run_compliance_check() with the report dict initialization and the for loop running all three checks.
- print_summary() with the formatted terminal output and severity markers.
- save_report() writing to reports/iam_compliance_report.json.
- main() calling run, save, and print in order, plus the if __name__ == '__main__' guard.
You now have a production compliance checker that replaces hours of manual MFA auditing with a single command. Next up, you will build the CloudTrail audit evidence generator that proves whether a user actually accessed AWS during a specific time window.
Build the CloudTrail Audit Evidence Generator
Your IAM compliance checker now scans users and generates structured findings. The next question is: how do you prove a specific person stopped accessing AWS after they were deprovisioned?
When a contractor leaves, SOX auditors need evidence that their access was fully removed. This step builds a script that queries CloudTrail for every API call a specific user made during a date range. You will run it against your own account and see real activity (or confirmed silence) formatted as audit evidence.
In this step, get ready to:
- Create a script that accepts a username and date range as command-line arguments.
- Query CloudTrail for all API calls by that user and build structured audit evidence.
- Output a JSON report file and a human-readable terminal summary suitable for SOX compliance.
Create the argument parser and event lookup function
The script needs three pieces of information from the person running it: which IAM username to audit, and the start and end dates for the audit window. Python's argparse module handles that cleanly from the command line.
- Create a new file called cloudtrail_audit.py inside your python/ directory by adding the following code:
"""\nCloudTrail Audit Evidence Generator\nQueries all API calls by a specific user over a date range.\nGenerates formatted audit evidence for SOX compliance.\nReplaces manual audit evidence gathering for contractor offboarding.\n"""\n\nimport argparse\nimport boto3\nimport json\nimport os\nfrom datetime import datetime, timezone, timedelta\n\n\nREPORTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'reports')\n\n\ndef parse_arguments():\n parser = argparse.ArgumentParser(description='Generate CloudTrail audit evidence for a specific user.')\n parser.add_argument('--username', required=True, help='IAM username to audit')\n parser.add_argument('--start-date', required=True, help='Start date for audit period (YYYY-MM-DD)')\n parser.add_argument('--end-date', required=True, help='End date for audit period (YYYY-MM-DD)')\n return parser.parse_args()
What does this code do?
The module docstring explains what this script automates. The imports bring in argparse for CLI argument handling, boto3 for AWS API calls, and json/os for report file management.
REPORTS_DIR points to the same reports/ folder your compliance checker uses. The parse_arguments() function defines three required flags: --username, --start-date, and --end-date.
Now add the function that actually queries CloudTrail. This goes directly below parse_arguments() in the same file.
- Add the event lookup function below parse_arguments() by pasting this code:
\ndef lookup_user_events(username, start_time, end_time):\n """Query CloudTrail for all events by the specified user."""\n client = boto3.client('cloudtrail')\n paginator = client.get_paginator('lookup_events')\n\n events = []\n page_iterator = paginator.paginate(\n LookupAttributes=[\n {\n 'AttributeKey': 'Username',\n 'AttributeValue': username\n }\n ],\n StartTime=start_time,\n EndTime=end_time\n )\n\n for page in page_iterator:\n for event in page.get('Events', []):\n cloud_trail_event = json.loads(event.get('CloudTrailEvent', '{}'))\n events.append({\n 'event_name': event.get('EventName', 'Unknown'),\n 'event_time': event.get('EventTime').strftime('%Y-%m-%d %H:%M:%S UTC') if event.get('EventTime') else 'Unknown',\n 'event_source': cloud_trail_event.get('eventSource', 'Unknown'),\n 'source_ip': cloud_trail_event.get('sourceIPAddress', 'Unknown'),\n 'aws_region': cloud_trail_event.get('awsRegion', 'Unknown'),\n 'user_agent': cloud_trail_event.get('userAgent', 'Unknown'),\n 'resources': [\n {'type': r.get('ResourceType', ''), 'name': r.get('ResourceName', '')}\n for r in event.get('Resources', [])\n ]\n })\n\n return events
What does this code do?
This function creates a CloudTrail paginator that filters events by username. The LookupAttributes parameter tells CloudTrail to return only events where the specified user was the actor.
For each event, it parses the nested CloudTrailEvent JSON string to extract details like source IP and region. Pagination ensures you capture all events even if there are hundreds across the date range.
- Save cloudtrail_audit.py.
- Verify that argparse is wired up correctly by running this command from the python/ directory:
python cloudtrail_audit.py --help
You should see a usage message showing the three required arguments: --username, --start-date, and --end-date.
Not seeing the help message?
- Make sure your virtual environment is active. You should see (venv) in your terminal prompt.
- Confirm you are inside the python/ directory, not the repository root.
- Check for typos in the import lines. A misspelled module name causes an ImportError before argparse runs.
Help me debug my cloudtrail_audit.py argparse setup.
Build the structured audit report
Raw event data from CloudTrail is useful, but an auditor needs a structured report with a summary, date range, and organized findings. These two functions transform the raw events into a report dictionary and save it as JSON.
- Add the report-building functions below lookup_user_events() in cloudtrail_audit.py:
\ndef build_audit_report(username, start_date, end_date, events):\n """Build structured audit evidence report."""\n services_accessed = set()\n for event in events:\n services_accessed.add(event['event_source'])\n\n report = {\n 'audit_evidence': {\n 'generated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),\n 'target_user': username,\n 'audit_period': {\n 'start': start_date,\n 'end': end_date\n },\n 'summary': {\n 'total_events': len(events),\n 'unique_services': sorted(list(services_accessed)),\n 'services_count': len(services_accessed)\n },\n 'events': events\n }\n }\n\n return report\n\n\ndef save_report(report, username, start_date, end_date):\n """Save audit evidence report as JSON."""\n os.makedirs(REPORTS_DIR, exist_ok=True)\n filename = f\"cloudtrail_audit_{username}_{start_date}_to_{end_date}.json\"\n filepath = os.path.join(REPORTS_DIR, filename)\n with open(filepath, 'w') as f:\n json.dump(report, f, indent=2)\n return filepath
What does this code do?
build_audit_report() collects unique AWS services the user accessed and packages everything into a structured dictionary. The audit_evidence wrapper makes the JSON self-documenting for anyone reading it months later.
save_report() writes the report to a filename that includes the username and date range. This means you can run multiple audits and keep each one as a separate evidence file.
- Save cloudtrail_audit.py.
- Confirm the new functions import cleanly by running:
python -c "from cloudtrail_audit import build_audit_report, save_report; print('Import OK')"
You should see Import OK printed with no errors.
Seeing a SyntaxError?
- Check that the f-string in save_report() uses matching quotes. The filename uses double quotes inside the f-string.
- Verify the dictionary in build_audit_report() has matching braces. Count each opening { and closing }.
Help me fix the syntax error in my cloudtrail_audit.py report functions.
Add human-readable output and run the audit
A JSON file is great for machine processing, but an auditor sitting at a terminal wants a quick summary. The print_summary() function formats the evidence with clear headers and handles the critical zero-activity case explicitly.
- Add the first portion of the summary printer below save_report() in cloudtrail_audit.py. This covers the report header and the zero-event finding:
\ndef print_summary(report):\n """Print human-readable audit evidence summary."""\n evidence = report['audit_evidence']\n summary = evidence['summary']\n\n print()\n print(\"=\" * 60)\n print(\"CLOUDTRAIL AUDIT EVIDENCE REPORT\")\n print(\"=\" * 60)\n print(f\"Generated: {evidence['generated']}\")\n print(f\"Target User: {evidence['target_user']}\")\n print(f\"Audit Period: {evidence['audit_period']['start']} to {evidence['audit_period']['end']}\")\n print()\n print(f\"Total API Calls Found: {summary['total_events']}\")\n print(f\"Unique Services: {summary['services_count']}\")\n print()\n\n if summary['total_events'] == 0:\n print(\"-\" * 60)\n print(\"FINDING: NO ACTIVITY DETECTED\")\n print(\"-\" * 60)\n print(f\"User '{evidence['target_user']}' made ZERO API calls during\")\n print(f\"the audit period ({evidence['audit_period']['start']} to {evidence['audit_period']['end']}).\")\n print()\n print(\"This confirms the user had no access to AWS resources\")\n print(\"during this period. Suitable as evidence for access\")\n print(\"removal verification.\")
What does the zero-event case do?
When a deprovisioned contractor has zero API calls during the audit period, that is itself the evidence. The script prints a clear statement confirming no access occurred. This is exactly what a SOX auditor needs to close out the offboarding verification.
- Continue the print_summary() function by adding the else branch directly below the zero-event code. This handles the case where events ARE found:
else:\n print(\"-\" * 60)\n print(\"ACTIVITY TIMELINE:\")\n print(\"-\" * 60)\n print()\n print(f\"Services accessed: {', '.join(summary['unique_services'])}\")\n print()\n print(f\"{'Time':<22} {'Event':<35} {'Source IP':<16}\")\n print(\"-\" * 73)\n\n display_events = evidence['events'][:20]\n for event in display_events:\n print(f\"{event['event_time']:<22} {event['event_name']:<35} {event['source_ip']:<16}\")\n\n if len(evidence['events']) > 20:\n print(f\"\\n ... and {len(evidence['events']) - 20} more events (see full report)\")\n\n print()\n print(\"=\" * 60)
What does the activity timeline show?
When events exist, the script prints a formatted table of up to 20 events showing the timestamp, API call name, and source IP. If there are more than 20 events, it tells the auditor to check the full JSON report. The services list at the top gives a quick picture of which AWS services were accessed.
- Save cloudtrail_audit.py and confirm no syntax errors by running:
python -c "from cloudtrail_audit import print_summary; print('Import OK')"
You should see Import OK with no errors.
Now add the main() function that ties everything together. It validates dates, checks the 90-day CloudTrail retention limit, and orchestrates the full pipeline.
- Add the main() function and entry point below print_summary():
\ndef main():\n args = parse_arguments()\n\n try:\n start_time = datetime.strptime(args.start_date, '%Y-%m-%d').replace(tzinfo=timezone.utc)\n end_time = datetime.strptime(args.end_date, '%Y-%m-%d').replace(\n hour=23, minute=59, second=59, tzinfo=timezone.utc\n )\n except ValueError:\n print(\"Error: Dates must be in YYYY-MM-DD format.\")\n return\n\n if start_time > end_time:\n print(\"Error: Start date must be before end date.\")\n return\n\n ninety_days_ago = datetime.now(timezone.utc) - timedelta(days=90)\n if start_time < ninety_days_ago:\n print(f\"Warning: CloudTrail event history only retains the last 90 days.\")\n print(f\"Adjusting start date to {ninety_days_ago.strftime('%Y-%m-%d')}.\")\n start_time = ninety_days_ago\n\n print(f\"Querying CloudTrail for user '{args.username}'...\")\n print(f\"Period: {args.start_date} to {args.end_date}\")\n\n events = lookup_user_events(args.username, start_time, end_time)\n report = build_audit_report(args.username, args.start_date, args.end_date, events)\n filepath = save_report(report, args.username, args.start_date, args.end_date)\n print_summary(report)\n print(f\"Full report saved to: {filepath}\")\n\n\nif __name__ == '__main__':\n main()
What does main() do?
It parses the CLI arguments, converts date strings into timezone-aware datetime objects, and validates them. The 90-day check is critical because CloudTrail event history only retains the last 90 days of management events. If the learner asks for older data, the script warns them and adjusts automatically.
The end time is set to 23:59:59 on the end date so the full day is included in the query.
- Save cloudtrail_audit.py.
Before you run the full audit, what do you expect to see? Will it find events for your own username, or will the output be empty?
- Run the audit against your own IAM username by running this command (substitute your actual IAM username and a recent date range within the last 90 days):
python cloudtrail_audit.py --username [[IAM_USERNAME="your-iam-username"]] --start-date [[START_DATE="2025-06-01"]] --end-date [[END_DATE="2025-06-30"]]
You should see the audit evidence report printed to your terminal. Since you have been using this AWS account, you will likely see events listed in the activity timeline showing your recent API calls.
- Confirm the JSON report was created by running:
ls reports/
You should see a file named cloudtrail_audit_ followed by your username and date range, alongside the existing iam_compliance_report.json.
Seeing an AccessDeniedException?
- Your IAM user needs the cloudtrail:LookupEvents permission. If you are using AdministratorAccess, this should already be granted.
- Double-check that you spelled --username with your exact IAM username (case-sensitive).
- If your start date is older than 90 days, the script should auto-adjust. If you still see zero events, try a more recent date range.
Help me troubleshoot my CloudTrail audit script.
Why does CloudTrail only keep 90 days?
CloudTrail event history (the free, always-on feature) retains management events for 90 days. For longer retention, organizations set up a CloudTrail Trail that delivers events to S3. This script works with the built-in 90-day history, which is sufficient for most audit scenarios.
✔️ Awesome, I've got everything!
Great. Double check you have saved cloudtrail_audit.py and that your reports/ directory contains the new audit JSON file.
ⓧ I'd like to double check the full code
Your cloudtrail_audit.py file is built from the following sections you added above. Scroll up and confirm each is present in this order:
- Module docstring, imports, and REPORTS_DIR constant.
- parse_arguments() function with three required arguments.
- lookup_user_events() function with paginator and event parsing.
- build_audit_report() function building the audit evidence dictionary.
- save_report() function writing JSON to the reports directory.
- print_summary() function with both the zero-event and activity-timeline branches.
- main() function with date validation, 90-day check, and the if __name__ == '__main__' guard.
You now have two production scripts that automate hours of manual compliance and audit work. Next up, you will push this entire suite to GitHub as a professional repository ready to share.
Secret mission
Build the EC2 Cost Optimization Script
At the VA, you manually checked for forgotten EC2 instances burning money across regions. Can you automate that scan to find stopped instances wasting EBS costs, running instances invisible to finance because of missing tags, and generate a savings estimate?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and makes only read-only AWS API calls, so there are no ongoing costs.
Resources you used:
- The aws-automation-suite project folder on your Desktop (contains all Python scripts, virtual environment, and generated reports).
- GitHub repository (aws-automation-suite).
Keep everything running
No action needed. Choose this if you plan to add the Lambda, Bash, Ansible, or Terraform components to your suite next.
- Your project folder stays on your Desktop ready for the next component.
- Your GitHub repository remains public for your portfolio.
- The virtual environment uses around 50-100 MB of disk space.
Pause - I'll come back to this later
Since there are no running processes or cloud resources, pausing is the same as keeping. Your scripts only run when you execute them manually.
- No servers, containers, or background processes to stop.
- Your project folder and GitHub repo will be ready whenever you return.
Delete - I don't want to use this again
Remove all project files from your machine and optionally delete the GitHub repository.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/aws-automation-suite
Windows
rmdir /s /q %USERPROFILE%\Desktop\aws-automation-suite
- Confirm the folder is gone by running:
ls ~/Desktop/aws-automation-suite
You should see a "No such file or directory" message.
- To delete the GitHub repository, go to your aws-automation-suite repository on GitHub.
- Click Settings in the top navigation bar.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
Nice work! You just built a production-quality Python automation suite that replaces hours of manual compliance and audit work with scripts you can run in seconds.
Here's what you learned:
- Built a professional aws-automation-suite repository with the folder structure for all 5 components (Python, Lambda, Bash, Ansible, Terraform) ready for future automation scripts.
- Created an IAM Compliance Checker that scans all users and generates audit-ready JSON reports identifying MFA gaps, stale access keys, and inactive accounts.
- Created a CloudTrail Audit Evidence Generator that produces formatted proof of user activity (or inactivity) for SOX compliance, replacing manual contractor offboarding verification.
- Secret Mission: Built an EC2 Cost Optimization script that scans all regions for stopped instances wasting EBS costs and running instances missing required tags, replacing manual instance management.
Ready to quiz yourself?