cmc/cleberg.net

My personal web garden & blog.

clone: git clone https://gitbay.org/cmc/cleberg.net.git

main: content/blog/2026-02-21-auditing-aws-passwords.org · raw

  1#+date:        [2026-02-21 Sat 21:13:45]
  2#+title:       Auditing AWS Passwords
  3#+description: How to audit password policies and usage in AWS.
  4#+slug: auditing-aws-passwords
  5#+filetags:    :audit:
  6
  7One of the first controls an IT Auditor learns is how to audit passwords in any
  8number of IT systems. However, things have changed with the introduction of
  9cloud platforms. This post covers the process and results of auditing AWS
 10passwords.
 11
 12The scripts are available at [[https://github.com/audit-labs/audit-tools/tree/main/applications/aws/aws_password_policy][audit-labs/audit-tools]].
 13
 14* Scoping
 15
 16First thing first: scoping. To audit AWS passwords, we need to understand that
 17AWS IAM password policies only apply to users with console access. This isn't
 18the same as the password policy for an application built on top of AWS. It also
 19has no effect on users who authenticate through IAM Identity Center, which
 20delegates authentication to an external identity provider, such as Okta or
 21Active Directory.
 22
 23If the organization uses Identity Center exclusively and has no IAM users with
 24passwords, you will need to perform a different procedure.
 25
 26If they do have IAM users with console access, the script applies and is worth
 27testing.
 28
 29* What the Scripts Do
 30
 31The process runs in two steps:
 321. ~gather_policy.sh~ calls the AWS CLI to fetch the current IAM password policy,
 33   captures metadata (timestamp, AWS account, region, caller identity), and
 34   writes everything to a JSON file. This file is evidence of the policy's
 35   current state.
 362. ~evaluate_policy.py~ reads that JSON file, prompts you for the expected value
 37   of each setting, and exports a CSV report with a value of ~PASS~ or ~FAIL~ for
 38   each rule.
 39
 40* Prerequisites
 41
 42To run this script, you'll need:
 43
 44- Access to CloudShell or the AWS CLI utility installed and configured with
 45  credentials that have read access to ~iam:GetAccountPasswordPolicy~ and
 46  ~sts:GetCallerIdentity~
 47- ~jq~ installed (used by the Bash script to merge JSON objects)
 48- Python 3 installed
 49- Optional: ~uv~ installed (used to run the Python script)
 50
 51* Step 1: Gather the Policy
 52
 53Run the first script to pull the current policy from AWS:
 54
 55#+begin_src bash
 56chmod +x gather_policy.sh
 57./gather_policy.sh
 58#+end_src
 59
 60By default, this writes the output to ~policy_report.json~ in the current
 61directory. You can specify a custom path with the ~-o~ flag:
 62
 63#+begin_src bash
 64./gather_policy.sh -o /tmp/my_report.json
 65#+end_src
 66
 67The output is a JSON file with two top-level keys: ~metadata~ and ~PasswordPolicy~.
 68
 69#+begin_src json
 70{
 71  "metadata": {
 72    "report_timestamp_utc": "2025-12-15T01:29:52Z",
 73    "os_user": "cloudshell-user",
 74    "hostname": "",
 75    "working_directory": "/home/cloudshell-user",
 76    "aws_profile": "default",
 77    "aws_region": "eu-west-1",
 78    "aws_caller_identity": {
 79      "UserId": "214941490075",
 80      "Account": "214941490075",
 81      "Arn": "arn:aws:iam::214941490075:root"
 82    }
 83  },
 84  "PasswordPolicy": {
 85    "MinimumPasswordLength": 8,
 86    "RequireSymbols": true,
 87    "RequireNumbers": true,
 88    "RequireUppercaseCharacters": true,
 89    "RequireLowercaseCharacters": true,
 90    "AllowUsersToChangePassword": true,
 91    "ExpirePasswords": true,
 92    "MaxPasswordAge": 90,
 93    "PasswordReusePrevention": 4,
 94    "HardExpiry": false
 95  }
 96}
 97#+end_src
 98
 99The metadata block is what ties this evidence to a specific account and point in
100time. The ~aws_caller_identity~ field shows who ran the script and in which
101account.
102
103#+caption: Policy Evaluation Results
104#+attr_html: :alt Terminal output of evaluate_policy.py showing the interactive prompts and pass/fail summary for each password policy rule.
105[[https://img.cleberg.net/blog/20260221-auditing-aws-passwords/results.webp]]
106
107* Step 2: Evaluate the Policy
108
109Pass the JSON file to the evaluation script:
110
111#+begin_src bash
112uv run evaluate_policy.py policy_report.json
113#+end_src
114
115The script will prompt you for each of the ten settings. Press ~Enter~ to skip any
116setting you don't need to test. For numeric settings like ~MinimumPasswordLength~
117and ~MaxPasswordAge~, the script treats your input as a minimum, so the actual
118value must be greater than or equal to your expected value to pass. Boolean
119settings require an exact match.
120
121#+begin_src text
122=== Expected / Minimum Values (press <Enter> for N/A) ===
123
124Enter expected value for 'Minimum password length' (int) or press <Enter> to skip: 8
125Enter expected value for 'Require symbols (!@#$...)' (bool) or press <Enter> to skip: true
126Enter expected value for 'Require numbers (0-9)' (bool) or press <Enter> to skip: true
127Enter expected value for 'Require uppercase letters (A-Z)' (bool) or press <Enter> to skip: true
128Enter expected value for 'Require lowercase letters (a-z)' (bool) or press <Enter> to skip: true
129Enter expected value for 'Allow users to change password' (bool) or press <Enter> to skip: true
130Enter expected value for 'Expire passwords (enable aging)' (bool) or press <Enter> to skip: true
131Enter expected value for 'Maximum password age (days)' (int) or press <Enter> to skip: 90
132Enter expected value for 'Prevent password reuse (last N)' (int) or press <Enter> to skip: 4
133Enter expected value for 'Hard expiry (no grace period)' (bool) or press <Enter> to skip: false
134
135Audit CSV written to: policy_audit_20251215T014323Z.csv
136
137Summary:
138   1. Minimum password length             -> PASS
139   2. Require symbols (!@#$...)           -> PASS
140   3. Require numbers (0-9)               -> PASS
141   4. Require uppercase letters (A-Z)     -> PASS
142   5. Require lowercase letters (a-z)     -> PASS
143   6. Allow users to change password      -> PASS
144   7. Expire passwords (enable aging)     -> PASS
145   8. Maximum password age (days)         -> PASS
146   9. Prevent password reuse (last N)     -> PASS
147  10. Hard expiry (no grace period)       -> PASS
148
149--- End of report ---
150#+end_src
151
152* Reading the CSV
153
154The CSV is your evidence. It includes the metadata header from the JSON file, so
155the account ID, timestamp, and caller identity are embedded directly in the
156file.
157
158#+begin_src csv
159# report_timestamp_utc: 2025-12-15T01:29:52Z
160# os_user: cloudshell-user
161# hostname:
162# working_directory: /home/cloudshell-user
163# aws_profile: default
164# aws_region: eu-west-1
165# aws_caller_identity: {'UserId': '214941490075', 'Account': '214941490075', 'Arn': 'arn:aws:iam::214941490075:root'}
166
167Rule#,Policy-Item,Expected,Actual,Result
1681,Minimum password length,8,8,PASS
1692,Require symbols (!@#$...),true,true,PASS
1703,Require numbers (0-9),true,true,PASS
1714,Require uppercase letters (A-Z),true,true,PASS
1725,Require lowercase letters (a-z),true,true,PASS
1736,Allow users to change password,true,true,PASS
1747,Expire passwords (enable aging),true,true,PASS
1758,Maximum password age (days),90,90,PASS
1769,Prevent password reuse (last N),4,4,PASS
17710,Hard expiry (no grace period),false,false,PASS
178#+end_src
179
180You may use this evidence in any form, but I suggest having your AWS contact
181screenshot the results directly within their CloudShell or AWS CLI session. This
182allows you to prove that the data was not modified after the script was run.
183
184* Common Exceptions and False Positives
185
186- *No policy defined*: If ~gather_policy.sh~ exits with a ~NoSuchEntity~ error, the
187  account has no IAM password policy configured. If you were expecting a
188  password policy, document it as a missing control.
189- *HardExpiry: false*: This setting controls whether users are locked out
190  immediately when their password expires or given a grace period to change it.
191  ~false~ is often intentional to avoid lockouts. Check the organization's policy
192  before calling it a finding. Additionally, check if the organization has
193  security exceptions in place before noting a deficiency.
194- *MaxPasswordAge and forced rotation*: A 90-day rotation requirement is common in
195  older policies and frameworks like CIS. NIST 800-63B no longer recommends
196  forced rotation unless there's evidence of compromise. Know which framework
197  you're auditing against before writing up a finding for this setting. Confirm
198  with the organization to understand which framework they used to write their
199  policy.
200- *PasswordReusePrevention*: AWS allows a maximum of 24 previous passwords. If
201  your organization's policy requires a higher number than AWS supports,
202  document the platform limitation rather than raising it as a deficiency.
203
204* How to Write Up the Finding
205
206If a setting fails, here's how to frame it:
207
208- *Deficiency:* The ~MinimumPasswordLength~ setting in the AWS IAM password policy
209  is configured to ~6~, which is below the organization's requirement of ~8~
210  characters.
211- *Root Cause:* Due to {{ root cause }}, the policy was configured to enforce a
212  ~MinimumPasswordLength~ of ~6~.
213- *Risk:* Shorter passwords are more susceptible to brute-force and credential
214  stuffing attacks, increasing the likelihood of unauthorized access to the AWS
215  console.
216- *Evidence:* Refer to ~policy_audit_<timestamp>.csv~ for documentation of testing.
217
218The same structure applies to any other failing rule. For boolean settings, the
219deficiency is simply that the actual value does not match the expected value.
220For numeric settings, the deficiency is that the actual value falls below the
221required minimum.