aboutsummaryrefslogtreecommitdiff
path: root/content/blog/2026-02-21-auditing-aws-passwords.org
blob: 71ef799b239cda55ebe1ea35f46961f39ba3d899 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#+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 <Enter> for N/A) ===

Enter expected value for 'Minimum password length' (int) or press <Enter> to skip: 8
Enter expected value for 'Require symbols (!@#$...)' (bool) or press <Enter> to skip: true
Enter expected value for 'Require numbers (0-9)' (bool) or press <Enter> to skip: true
Enter expected value for 'Require uppercase letters (A-Z)' (bool) or press <Enter> to skip: true
Enter expected value for 'Require lowercase letters (a-z)' (bool) or press <Enter> to skip: true
Enter expected value for 'Allow users to change password' (bool) or press <Enter> to skip: true
Enter expected value for 'Expire passwords (enable aging)' (bool) or press <Enter> to skip: true
Enter expected value for 'Maximum password age (days)' (int) or press <Enter> to skip: 90
Enter expected value for 'Prevent password reuse (last N)' (int) or press <Enter> to skip: 4
Enter expected value for 'Hard expiry (no grace period)' (bool) or press <Enter> 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_<timestamp>.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.