#!/usr/bin/env python3 """Agent for auditing and hardening AWS IAM permissions using least-privilege principles.""" import boto3 import json import csv import argparse from datetime import datetime, timedelta, timezone from base64 import b64decode def get_credential_report(): """Generate and parse the IAM credential report.""" iam = boto3.client("iam") iam.generate_credential_report() import time for _ in range(10): try: response = iam.get_credential_report() content = b64decode(response["Content"]).decode("utf-8") lines = content.strip().split("\n") reader = csv.DictReader(lines) report = list(reader) print(f"[*] Credential report: {len(report)} users") return report except iam.exceptions.CredentialReportNotReadyException: time.sleep(2) return [] def find_stale_access_keys(max_age_days=90): """Find access keys older than the specified threshold.""" iam = boto3.client("iam") cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days) stale_keys = [] users = iam.list_users()["Users"] for user in users: keys = iam.list_access_keys(UserName=user["UserName"])["AccessKeyMetadata"] for key in keys: if key["CreateDate"] < cutoff and key["Status"] == "Active": last_used = iam.get_access_key_last_used(AccessKeyId=key["AccessKeyId"]) last_used_date = last_used["AccessKeyLastUsed"].get("LastUsedDate", "Never") stale_keys.append({ "user": user["UserName"], "key_id": key["AccessKeyId"], "created": key["CreateDate"].isoformat(), "last_used": str(last_used_date), }) print(f" [!] Stale key: {user['UserName']} - {key['AccessKeyId']} " f"(created: {key['CreateDate'].strftime('%Y-%m-%d')})") print(f"[*] Found {len(stale_keys)} stale access keys (>{max_age_days} days)") return stale_keys def find_overprivileged_roles(): """Identify roles with AdministratorAccess or wildcard policies.""" iam = boto3.client("iam") findings = [] roles = iam.list_roles()["Roles"] for role in roles: if role["Path"].startswith("/aws-service-role/"): continue attached = iam.list_attached_role_policies(RoleName=role["RoleName"])["AttachedPolicies"] for policy in attached: if policy["PolicyName"] in ("AdministratorAccess", "PowerUserAccess"): findings.append({ "role": role["RoleName"], "policy": policy["PolicyName"], "severity": "CRITICAL" if policy["PolicyName"] == "AdministratorAccess" else "HIGH", }) print(f" [!] {role['RoleName']} has {policy['PolicyName']}") inline_policies = iam.list_role_policies(RoleName=role["RoleName"])["PolicyNames"] for pol_name in inline_policies: pol_doc = iam.get_role_policy(RoleName=role["RoleName"], PolicyName=pol_name)["PolicyDocument"] for stmt in pol_doc.get("Statement", []): actions = stmt.get("Action", []) resources = stmt.get("Resource", []) if isinstance(actions, str): actions = [actions] if isinstance(resources, str): resources = [resources] if "*" in actions and "*" in resources and stmt.get("Effect") == "Allow": findings.append({ "role": role["RoleName"], "policy": f"inline:{pol_name}", "severity": "CRITICAL", }) print(f" [!] {role['RoleName']} inline policy '{pol_name}' has *:* Allow") print(f"[*] Found {len(findings)} overprivileged roles") return findings def check_mfa_status(): """Check which IAM users have MFA enabled.""" iam = boto3.client("iam") users_without_mfa = [] users = iam.list_users()["Users"] for user in users: mfa_devices = iam.list_mfa_devices(UserName=user["UserName"])["MFADevices"] if not mfa_devices: login_profile = None try: iam.get_login_profile(UserName=user["UserName"]) login_profile = True except iam.exceptions.NoSuchEntityException: login_profile = False if login_profile: users_without_mfa.append(user["UserName"]) print(f" [!] {user['UserName']} has console access but NO MFA") print(f"[*] {len(users_without_mfa)} console users without MFA") return users_without_mfa def check_access_analyzer(region="us-east-1"): """Check IAM Access Analyzer findings for external access.""" aa = boto3.client("accessanalyzer", region_name=region) analyzers = aa.list_analyzers(Type="ACCOUNT")["analyzers"] if not analyzers: print(" [-] No Access Analyzer configured. Creating one...") aa.create_analyzer(analyzerName="account-analyzer", type="ACCOUNT") return [] analyzer_arn = analyzers[0]["arn"] findings = aa.list_findings(analyzerArn=analyzer_arn, filter={"status": {"eq": ["ACTIVE"]}})["findings"] print(f"[*] Access Analyzer active findings: {len(findings)}") for f in findings[:10]: print(f" [!] {f['resourceType']}: {f['resource']} - {f.get('condition', {})}") return findings def generate_audit_report(stale_keys, overpriv_roles, no_mfa, aa_findings, output_path): """Generate a consolidated IAM audit report.""" report = { "audit_date": datetime.now().isoformat(), "summary": { "stale_access_keys": len(stale_keys), "overprivileged_roles": len(overpriv_roles), "users_without_mfa": len(no_mfa), "access_analyzer_findings": len(aa_findings), }, "stale_access_keys": stale_keys, "overprivileged_roles": overpriv_roles, "users_without_mfa": no_mfa, } with open(output_path, "w") as f: json.dump(report, f, indent=2, default=str) print(f"\n[*] Audit report saved to {output_path}") total = len(stale_keys) + len(overpriv_roles) + len(no_mfa) + len(aa_findings) print(f"[*] Total findings: {total}") def main(): parser = argparse.ArgumentParser(description="AWS IAM Security Audit Agent") parser.add_argument("action", choices=["full-audit", "stale-keys", "overpriv-roles", "mfa-check", "access-analyzer", "credential-report"]) parser.add_argument("--max-key-age", type=int, default=90, help="Max access key age in days") parser.add_argument("--region", default="us-east-1") parser.add_argument("-o", "--output", default="iam_audit_report.json") args = parser.parse_args() if args.action == "credential-report": get_credential_report() elif args.action == "stale-keys": find_stale_access_keys(args.max_key_age) elif args.action == "overpriv-roles": find_overprivileged_roles() elif args.action == "mfa-check": check_mfa_status() elif args.action == "access-analyzer": check_access_analyzer(args.region) elif args.action == "full-audit": print("[*] Running full IAM security audit...") stale = find_stale_access_keys(args.max_key_age) overpriv = find_overprivileged_roles() no_mfa = check_mfa_status() aa = check_access_analyzer(args.region) generate_audit_report(stale, overpriv, no_mfa, aa, args.output) if __name__ == "__main__": main()