#+date: [2026-02-21 Sat 21:13:45] #+title: Auditing AWS Passwords #+description: How to audit password policies and usage in AWS. #+slug: auditing-aws-passwords #+filetags: :audit: One of the first controls an IT Auditor learns is how to audit passwords in any number of IT systems. However, things have changed with the introduction of cloud platforms. This post covers the process and results of auditing AWS passwords. The scripts are available at [[https://github.com/audit-labs/audit-tools/tree/main/applications/aws/aws_password_policy][audit-labs/audit-tools]]. * Scoping First thing first: scoping. To audit AWS passwords, we need to understand that AWS IAM password policies only apply to users with console access. This isn't the same as the password policy for an application built on top of AWS. It also has no effect on users who authenticate through IAM Identity Center, which delegates authentication to an external identity provider, such as Okta or Active Directory. If the organization uses Identity Center exclusively and has no IAM users with passwords, you will need to perform a different procedure. If they do have IAM users with console access, the script applies and is worth testing. * What the Scripts Do The process runs in two steps: 1. ~gather_policy.sh~ calls the AWS CLI to fetch the current IAM password policy, captures metadata (timestamp, AWS account, region, caller identity), and writes everything to a JSON file. This file is evidence of the policy's current state. 2. ~evaluate_policy.py~ reads that JSON file, prompts you for the expected value of each setting, and exports a CSV report with a value of ~PASS~ or ~FAIL~ for each rule. * Prerequisites To run this script, you'll need: - Access to CloudShell or the AWS CLI utility installed and configured with credentials that have read access to ~iam:GetAccountPasswordPolicy~ and ~sts:GetCallerIdentity~ - ~jq~ installed (used by the Bash script to merge JSON objects) - Python 3 installed - Optional: ~uv~ installed (used to run the Python script) * Step 1: Gather the Policy Run the first script to pull the current policy from AWS: #+begin_src bash chmod +x gather_policy.sh ./gather_policy.sh #+end_src By default, this writes the output to ~policy_report.json~ in the current directory. You can specify a custom path with the ~-o~ flag: #+begin_src bash ./gather_policy.sh -o /tmp/my_report.json #+end_src The output is a JSON file with two top-level keys: ~metadata~ and ~PasswordPolicy~. #+begin_src json { "metadata": { "report_timestamp_utc": "2025-12-15T01:29:52Z", "os_user": "cloudshell-user", "hostname": "", "working_directory": "/home/cloudshell-user", "aws_profile": "default", "aws_region": "eu-west-1", "aws_caller_identity": { "UserId": "214941490075", "Account": "214941490075", "Arn": "arn:aws:iam::214941490075:root" } }, "PasswordPolicy": { "MinimumPasswordLength": 8, "RequireSymbols": true, "RequireNumbers": true, "RequireUppercaseCharacters": true, "RequireLowercaseCharacters": true, "AllowUsersToChangePassword": true, "ExpirePasswords": true, "MaxPasswordAge": 90, "PasswordReusePrevention": 4, "HardExpiry": false } } #+end_src The metadata block is what ties this evidence to a specific account and point in time. The ~aws_caller_identity~ field shows who ran the script and in which account. #+caption: Policy Evaluation Results #+attr_html: :alt Terminal output of evaluate_policy.py showing the interactive prompts and pass/fail summary for each password policy rule. [[https://img.cleberg.net/blog/20260221-auditing-aws-passwords/results.webp]] * Step 2: Evaluate the Policy Pass the JSON file to the evaluation script: #+begin_src bash uv run evaluate_policy.py policy_report.json #+end_src The script will prompt you for each of the ten settings. Press ~Enter~ to skip any setting you don't need to test. For numeric settings like ~MinimumPasswordLength~ and ~MaxPasswordAge~, the script treats your input as a minimum, so the actual value must be greater than or equal to your expected value to pass. Boolean settings require an exact match. #+begin_src text === Expected / Minimum Values (press for N/A) === Enter expected value for 'Minimum password length' (int) or press to skip: 8 Enter expected value for 'Require symbols (!@#$...)' (bool) or press to skip: true Enter expected value for 'Require numbers (0-9)' (bool) or press to skip: true Enter expected value for 'Require uppercase letters (A-Z)' (bool) or press to skip: true Enter expected value for 'Require lowercase letters (a-z)' (bool) or press to skip: true Enter expected value for 'Allow users to change password' (bool) or press to skip: true Enter expected value for 'Expire passwords (enable aging)' (bool) or press to skip: true Enter expected value for 'Maximum password age (days)' (int) or press to skip: 90 Enter expected value for 'Prevent password reuse (last N)' (int) or press to skip: 4 Enter expected value for 'Hard expiry (no grace period)' (bool) or press to skip: false Audit CSV written to: policy_audit_20251215T014323Z.csv Summary: 1. Minimum password length -> PASS 2. Require symbols (!@#$...) -> PASS 3. Require numbers (0-9) -> PASS 4. Require uppercase letters (A-Z) -> PASS 5. Require lowercase letters (a-z) -> PASS 6. Allow users to change password -> PASS 7. Expire passwords (enable aging) -> PASS 8. Maximum password age (days) -> PASS 9. Prevent password reuse (last N) -> PASS 10. Hard expiry (no grace period) -> PASS --- End of report --- #+end_src * Reading the CSV The CSV is your evidence. It includes the metadata header from the JSON file, so the account ID, timestamp, and caller identity are embedded directly in the file. #+begin_src csv # report_timestamp_utc: 2025-12-15T01:29:52Z # os_user: cloudshell-user # hostname: # working_directory: /home/cloudshell-user # aws_profile: default # aws_region: eu-west-1 # aws_caller_identity: {'UserId': '214941490075', 'Account': '214941490075', 'Arn': 'arn:aws:iam::214941490075:root'} Rule#,Policy-Item,Expected,Actual,Result 1,Minimum password length,8,8,PASS 2,Require symbols (!@#$...),true,true,PASS 3,Require numbers (0-9),true,true,PASS 4,Require uppercase letters (A-Z),true,true,PASS 5,Require lowercase letters (a-z),true,true,PASS 6,Allow users to change password,true,true,PASS 7,Expire passwords (enable aging),true,true,PASS 8,Maximum password age (days),90,90,PASS 9,Prevent password reuse (last N),4,4,PASS 10,Hard expiry (no grace period),false,false,PASS #+end_src You may use this evidence in any form, but I suggest having your AWS contact screenshot the results directly within their CloudShell or AWS CLI session. This allows you to prove that the data was not modified after the script was run. * Common Exceptions and False Positives - *No policy defined*: If ~gather_policy.sh~ exits with a ~NoSuchEntity~ error, the account has no IAM password policy configured. If you were expecting a password policy, document it as a missing control. - *HardExpiry: false*: This setting controls whether users are locked out immediately when their password expires or given a grace period to change it. ~false~ is often intentional to avoid lockouts. Check the organization's policy before calling it a finding. Additionally, check if the organization has security exceptions in place before noting a deficiency. - *MaxPasswordAge and forced rotation*: A 90-day rotation requirement is common in older policies and frameworks like CIS. NIST 800-63B no longer recommends forced rotation unless there's evidence of compromise. Know which framework you're auditing against before writing up a finding for this setting. Confirm with the organization to understand which framework they used to write their policy. - *PasswordReusePrevention*: AWS allows a maximum of 24 previous passwords. If your organization's policy requires a higher number than AWS supports, document the platform limitation rather than raising it as a deficiency. * How to Write Up the Finding If a setting fails, here's how to frame it: - *Deficiency:* The ~MinimumPasswordLength~ setting in the AWS IAM password policy is configured to ~6~, which is below the organization's requirement of ~8~ characters. - *Root Cause:* Due to {{ root cause }}, the policy was configured to enforce a ~MinimumPasswordLength~ of ~6~. - *Risk:* Shorter passwords are more susceptible to brute-force and credential stuffing attacks, increasing the likelihood of unauthorized access to the AWS console. - *Evidence:* Refer to ~policy_audit_.csv~ for documentation of testing. The same structure applies to any other failing rule. For boolean settings, the deficiency is simply that the actual value does not match the expected value. For numeric settings, the deficiency is that the actual value falls below the required minimum.