Commit 8386d675f5
Unregistered key
Layout: unified · split
content/blog/2026-02-21-auditing-aws-iam.org +106 −132
| @@ -1,44 +1,50 @@ | ||
| 1 | 1 | #+date: [2026-02-21 Sat 18:58:39] |
| 2 | 2 | #+title: Auditing AWS IAM Identity Center Assignments |
| 3 | #+description: How to audit IAM Identity Center access assignments in AWS using a shell script, including what to look for, common exceptions, and how to write up findings. | |
| 3 | #+description: Learn how to audit IAM user assignments in AWS. | |
| 4 | 4 | #+slug: auditing-aws-iam |
| 5 | 5 | |
| 6 | 6 | If you've ever asked an IT team for a list of who has access to an AWS account, |
| 7 | 7 | you've probably received a spreadsheet that was already out of date. Or worse, |
| 8 | an out-of-context screenshot that doesn't explain anything. IAM Identity Center | |
| 9 | doesn't make this easy to extract through the console, and most teams don't have | |
| 10 | a repeatable process for pulling it. This post walks through a script that does | |
| 11 | it for you and explains what to look for once you have the output. | |
| 8 | an out-of-context screenshot that doesn't explain anything. Or worse, they don't | |
| 9 | know either and ignore you, hoping you forget about the request (ask me how I | |
| 10 | know!). | |
| 11 | ||
| 12 | To make it worse, IAM Identity Center doesn't make this easy to extract through | |
| 13 | the console, and most teams don't have a repeatable process for pulling it. | |
| 14 | ||
| 15 | To solve this problem, this post walks through a script that does it for you and | |
| 16 | explains what to look for once you have the output. This is something I've used | |
| 17 | in practice and want to share, as cloud auditing knowledge seems scarce. | |
| 12 | 18 | |
| 13 | 19 | The script is available at [[https://github.com/audit-labs/audit-tools/blob/main/applications/aws/aws_iam_users.sh][audit-labs/audit-tools]]. |
| 14 | 20 | |
| 15 | 21 | * Background: How AWS Provisions Access |
| 16 | 22 | |
| 17 | 23 | Before running anything, it helps to understand the different ways AWS can be |
| 18 | architected to grant access. You may encounter any of these in practice, | |
| 19 | sometimes within the same organization, and knowing how they differ tells you | |
| 20 | where to look during an audit and which questions to ask. | |
| 24 | architected to grant access. You may encounter any one (or all) of these in | |
| 25 | practice, sometimes within the same organization. Knowing how they differ tells | |
| 26 | you where to look during an audit and which questions to ask. | |
| 21 | 27 | |
| 22 | ** Traditional IAM Users | |
| 28 | ** IAM Users | |
| 23 | 29 | |
| 24 | The original AWS access model. An IAM user is an identity created directly | |
| 25 | inside a single AWS account. It has a username, optional console password, and | |
| 30 | This is the original AWS access model. An IAM user is an identity created | |
| 31 | directly inside a single AWS account (e.g., ~iamuser1~ created inside AWS user | |
| 32 | ~cmc~). This type of account has a username, an optional console password, and | |
| 26 | 33 | optional access keys for programmatic access. Permissions are granted by |
| 27 | 34 | attaching IAM policies directly to the user, or by adding the user to an IAM |
| 28 | 35 | group that has policies attached to it. |
| 29 | 36 | |
| 30 | You'll still see IAM users in older environments, smaller organizations, and in | |
| 31 | cases where a service or application needs long-lived programmatic credentials. | |
| 32 | 37 | The core problem with IAM users at scale is that they're account-scoped, meaning |
| 33 | 38 | a user in ~Account A~ has no relationship to a user in ~Account B~, even if they're |
| 34 | 39 | the same person. Organizations with dozens or hundreds of accounts end up with a |
| 35 | 40 | fragmented identity landscape that's difficult to review and even harder to |
| 36 | 41 | deprovision consistently. |
| 37 | 42 | |
| 38 | From an audit perspective, IAM users are high-risk when they have active access | |
| 39 | keys (long-lived credentials that don't expire unless explicitly rotated or | |
| 40 | revoked) or when policies are attached directly to the user rather than | |
| 41 | inherited through a group. | |
| 43 | From an audit perspective, these are key risks to understand during your | |
| 44 | walkthrough of the environment. Additionally, you should note that IAM users are | |
| 45 | high-risk when they have active access keys (long-lived credentials that don't | |
| 46 | expire unless explicitly rotated or revoked) or when policies are attached | |
| 47 | directly to the user rather than inherited through a group. | |
| 42 | 48 | |
| 43 | 49 | ** IAM Groups |
| 44 | 50 | |
| @@ -86,30 +92,31 @@ Policies are the documents that define permissions. They're not an access | ||
| 86 | 92 | pattern on their own, but understanding the three types helps when reviewing |
| 87 | 93 | output from any access audit: |
| 88 | 94 | |
| 89 | - *AWS managed policies* are created and maintained by AWS. Examples include | |
| 90 | ~AdministratorAccess~, ~ReadOnlyAccess~, and job-function policies like ~Billing~. | |
| 91 | They're convenient but broad: ~AdministratorAccess~ is a single policy that | |
| 92 | grants full access to every AWS service. | |
| 93 | - *Customer managed policies* are created by the organization. They offer more | |
| 94 | precise control and can be reused across users, groups, and roles. Better | |
| 95 | practice than relying entirely on AWS managed policies for anything sensitive. | |
| 96 | - *Inline policies* are embedded directly into a specific user, group, or role | |
| 97 | rather than existing as a standalone resource. They can't be reused and are | |
| 98 | easier to miss during reviews. When you see an inline policy in audit output, | |
| 99 | treat it as something that warrants a closer look — they're often added ad hoc | |
| 100 | to solve a specific problem and not always well documented. | |
| 95 | 1. *AWS managed policies* are created and maintained by AWS. Examples include | |
| 96 | ~AdministratorAccess~, ~ReadOnlyAccess~, and job-function policies like ~Billing~. | |
| 97 | They're convenient but broad: ~AdministratorAccess~ is a single policy that | |
| 98 | grants full access to every AWS service. | |
| 99 | 2. *Customer managed policies* are created by the organization. They offer more | |
| 100 | precise control and can be reused across users, groups, and roles. Better | |
| 101 | practice than relying entirely on AWS managed policies for anything | |
| 102 | sensitive. | |
| 103 | 3. *Inline policies* are embedded directly into a specific user, group, or role | |
| 104 | rather than existing as a standalone resource. They can't be reused and are | |
| 105 | easier to miss during reviews. When you see an inline policy in audit output, | |
| 106 | treat it as something that warrants a closer look — they're often added ad | |
| 107 | hoc to solve a specific problem and not always well documented. | |
| 101 | 108 | |
| 102 | 109 | ** IAM Identity Center |
| 103 | 110 | |
| 104 | 111 | IAM Identity Center (formerly AWS SSO) is the current recommended approach for |
| 105 | human access in multi-account AWS Organizations. It sits above individual | |
| 106 | accounts and provides centralized access management across all of them from a | |
| 107 | single place. | |
| 112 | human (end-user) access in multi-account AWS Organizations. It sits above | |
| 113 | individual accounts and provides centralized access management across all of | |
| 114 | them from a single place. | |
| 108 | 115 | |
| 109 | 116 | It connects to an identity source, such as AWS's own directory, Active |
| 110 | Directory, or an external IdP like Okta or Entra ID, and uses that as the source | |
| 111 | of truth for users and groups. Access is configured within Identity Center | |
| 112 | itself, not inside individual accounts. | |
| 117 | Directory, or an externali identity provider (IdP) like Okta or Entra ID, and | |
| 118 | uses that as the source of truth for users and groups. Access is configured | |
| 119 | within Identity Center itself, not inside individual accounts. | |
| 113 | 120 | |
| 114 | 121 | The key construct is the permission set: a named bundle of IAM policies |
| 115 | 122 | (managed, customer managed, and/or inline) that defines what a principal can do. |
| @@ -135,32 +142,35 @@ why. | ||
| 135 | 142 | |
| 136 | 143 | * What the Script Does |
| 137 | 144 | |
| 138 | The script works against a single named AWS account and produces a JSON report | |
| 139 | of every user and group assignment, along with the permission set and policies | |
| 140 | attached to each assignment. | |
| 145 | The script works by running commands against a single named AWS account and | |
| 146 | produces a JSON report of every user and group assignment, along with the | |
| 147 | permission set and policies attached to each assignment. | |
| 141 | 148 | |
| 142 | 149 | It runs in four steps: |
| 143 | 150 | |
| 144 | 151 | 1. Finds the IAM Identity Center instance and looks up the target account ID by |
| 145 | name | |
| 146 | 2. Lists all permission sets provisioned to that account | |
| 152 | name; | |
| 153 | 2. Lists all permission sets provisioned to that account; | |
| 147 | 154 | 3. For each permission set, retrieves all user and group assignments, resolves |
| 148 | 155 | their names from the Identity Store, and fetches the managed and inline |
| 149 | policies attached to the permission set | |
| 150 | 4. Writes everything to a JSON file named ~report_<account_name>.json~ | |
| 156 | policies attached to the permission set; | |
| 157 | 4. Writes everything to a JSON file named ~report_<account_name>.json~. | |
| 151 | 158 | |
| 152 | 159 | The script caches permission set details and principal names to avoid redundant |
| 153 | 160 | API calls when the same permission set or user appears in multiple assignments. |
| 154 | 161 | |
| 162 | At the end of this process, we will have the evidence we need to test controls | |
| 163 | such as administrative access or segregation of duties. | |
| 164 | ||
| 155 | 165 | * Prerequisites |
| 156 | 166 | |
| 157 | 167 | You'll need: |
| 158 | 168 | |
| 159 | - AWS CLI installed and configured with credentials that have read access to | |
| 160 | ~sso-admin~, ~identitystore~, and ~organizations~ | |
| 161 | - ~jq~ installed (used throughout the script for JSON parsing) | |
| 169 | - AWS CLI (or AWS CloudShell) installed and configured with credentials that | |
| 170 | have read access to ~sso-admin~, ~identitystore~, and ~organizations~; | |
| 171 | - ~jq~ installed (used throughout the script for JSON parsing); | |
| 162 | 172 | - The name of the AWS account you want to audit (must match exactly as it |
| 163 | appears in AWS Organizations) | |
| 173 | appears in AWS Organizations). | |
| 164 | 174 | |
| 165 | 175 | Set the account name at the top of the script before running: |
| 166 | 176 | |
| @@ -179,32 +189,6 @@ chmod +x aws_iam_users.sh | ||
| 179 | 189 | #+attr_html: :alt Terminal output of aws_iam_users.sh showing discovered assignments for the cmc account. |
| 180 | 190 | [[https://img.cleberg.net/blog/20260221-auditing-aws-iam/script.webp]] |
| 181 | 191 | |
| 182 | ||
| 183 | * Key Snippet: Resolving Principal Names | |
| 184 | ||
| 185 | The part of the script most worth understanding is how it resolves user and | |
| 186 | group names. AWS stores principals in Identity Center by ID, not by name. To get | |
| 187 | a readable name, the script calls the Identity Store API separately for each | |
| 188 | principal: | |
| 189 | ||
| 190 | #+begin_src bash | |
| 191 | if [ "$PRINCIPAL_TYPE" == "USER" ]; then | |
| 192 | PRINCIPAL_NAME=$(aws identitystore describe-user \ | |
| 193 | --identity-store-id "$IDENTITY_STORE_ID" \ | |
| 194 | --user-id "$PRINCIPAL_ID" \ | |
| 195 | --query "UserName" --output text 2>/dev/null) | |
| 196 | elif [ "$PRINCIPAL_TYPE" == "GROUP" ]; then | |
| 197 | PRINCIPAL_NAME=$(aws identitystore describe-group \ | |
| 198 | --identity-store-id "$IDENTITY_STORE_ID" \ | |
| 199 | --group-id "$PRINCIPAL_ID" \ | |
| 200 | --query "DisplayName" --output text 2>/dev/null) | |
| 201 | fi | |
| 202 | #+end_src | |
| 203 | ||
| 204 | This is also where the cache matters. If the same user appears across multiple | |
| 205 | permission set assignments (which is common), the script reuses the stored name | |
| 206 | rather than making a redundant API call. | |
| 207 | ||
| 208 | 192 | * Reading the Output |
| 209 | 193 | |
| 210 | 194 | The script produces a JSON array where each element represents a single |
| @@ -266,74 +250,64 @@ principal-to-permission-set assignment. Here's an example: | ||
| 266 | 250 | |
| 267 | 251 | A few things to look for when reviewing this output: |
| 268 | 252 | |
| 269 | *Direct user assignments*: ~iamtestuser1~ is assigned ~AdministratorAccess~ directly | |
| 270 | as a USER, not through a group. Access should generally be provisioned through | |
| 271 | groups so that provisioning and deprovisioning are tied to group membership, not | |
| 272 | managed case-by-case. A direct user assignment to a high-privilege permission | |
| 273 | set warrants a conversation with IT about why it exists. | |
| 274 | ||
| 275 | *AdministratorAccess*: This is AWS's broadest managed policy, which allows full | |
| 276 | access to everything in the account. Note who has it and whether that's | |
| 277 | appropriate. In many environments, even senior engineers shouldn't have standing | |
| 278 | ~AdministratorAccess~; it should be reserved for break-glass accounts or | |
| 279 | infrastructure teams with a documented need. | |
| 280 | ||
| 281 | *Inline policies*: The ~inline_policy~ field in this example contains a Deny | |
| 282 | statement for ~a4b:*~ (Alexa for Business). Inline policies attached to permission | |
| 283 | sets are worth reviewing, as they can add permissions or restrict them, and | |
| 284 | they're easy to miss if you're only looking at the managed policy name. | |
| 285 | ||
| 286 | *Multiple assignments for the same user*: ~iamtestuser1~ appears twice: once with | |
| 287 | ~AdministratorAccess~ and once with ~Billing~. This isn't automatically a problem, | |
| 288 | but a user with both full administrative access and explicit billing access is | |
| 289 | worth confirming with IT. | |
| 253 | - *Direct user assignments*: ~iamtestuser1~ is assigned ~AdministratorAccess~ directly | |
| 254 | as a USER, not through a group. Access should generally be provisioned through | |
| 255 | groups so that provisioning and deprovisioning are tied to group membership, | |
| 256 | not managed case-by-case. A direct user assignment to a high-privilege | |
| 257 | permission set warrants a conversation with IT about why it exists. | |
| 258 | - *AdministratorAccess*: This is AWS's broadest managed policy, which allows full | |
| 259 | access to everything in the account. Note who has it and whether that's | |
| 260 | appropriate. In many environments, even senior engineers shouldn't have | |
| 261 | standing ~AdministratorAccess~. It should be reserved for break-glass accounts | |
| 262 | or infrastructure teams with a documented need. | |
| 263 | - *Inline policies*: The ~inline_policy~ field in this example contains a Deny | |
| 264 | statement for ~a4b:*~. Inline policies attached to permission sets are worth | |
| 265 | reviewing, as they can add permissions or restrict them, and they're easy to | |
| 266 | miss if you're only looking at the managed policy name. | |
| 267 | - *Multiple assignments for the same user*: ~iamtestuser1~ appears twice: once with | |
| 268 | ~AdministratorAccess~ and once with ~Billing~. This isn't automatically a problem, | |
| 269 | but a user with both full administrative access and explicit billing access is | |
| 270 | worth confirming with IT. | |
| 290 | 271 | |
| 291 | 272 | * Common Exceptions and False Positives |
| 292 | 273 | |
| 293 | *Break-glass accounts*: Most organizations maintain at least one emergency account | |
| 294 | with direct ~AdministratorAccess~ that bypasses normal access controls. A direct | |
| 295 | USER assignment to ~AdministratorAccess~ may be intentional for this reason. Ask | |
| 296 | IT whether a break-glass account exists, confirm the username matches, and | |
| 297 | document it as an accepted exception if appropriate. | |
| 298 | ||
| 299 | *Small organizations or early-stage environments*: Some teams haven't implemented | |
| 300 | group-based access management yet and assign permissions directly to users | |
| 301 | across the board. This isn't a false positive, it's a control gap, but the | |
| 302 | finding should reflect the scale and maturity of the environment. A two-person | |
| 303 | startup with direct assignments is a different risk than an enterprise doing the | |
| 304 | same thing. | |
| 305 | ||
| 306 | *Service or automation accounts*: Occasionally a USER principal in Identity Center | |
| 307 | is a service account rather than a human. These may legitimately have specific | |
| 308 | permission sets assigned directly. Confirm the account's purpose before writing | |
| 309 | it up. | |
| 274 | - *Break-glass accounts*: Most organizations maintain at least one emergency | |
| 275 | account with direct ~AdministratorAccess~ that bypasses normal access controls. | |
| 276 | A direct USER assignment to ~AdministratorAccess~ may be intentional for this | |
| 277 | reason. Ask IT whether a break-glass account exists, confirm the username | |
| 278 | matches, and document it as an accepted exception if appropriate. | |
| 279 | - *Small organizations or early-stage environments*: Some teams haven't | |
| 280 | implemented group-based access management yet and assign permissions directly | |
| 281 | to users across the board. This is a control gap, but the finding should | |
| 282 | reflect the scale and maturity of the environment. A two-person startup with | |
| 283 | direct assignments is a different risk than an enterprise doing the same | |
| 284 | thing. This may or may not give rise to a deficiency. | |
| 285 | - *Service or automation accounts*: Occasionally a ~USER~ principal in Identity | |
| 286 | Center is a service account rather than a human. These may legitimately have | |
| 287 | specific permission sets assigned directly. Confirm the account's purpose | |
| 288 | before writing it up. | |
| 310 | 289 | |
| 311 | 290 | * How to Write Up the Finding |
| 312 | 291 | |
| 313 | 292 | If you identify a direct high-privilege user assignment that isn't a documented |
| 314 | 293 | exception, here's how to frame it: |
| 315 | ||
| 316 | *Condition:* User ~iamtestuser1~ is directly assigned the ~AdministratorAccess~ | |
| 317 | permission set in account ~cmc~, rather than receiving access through a group | |
| 318 | assignment. | |
| 319 | ||
| 320 | *Criteria:* Access to AWS accounts via IAM Identity Center should be provisioned | |
| 321 | through groups to ensure consistent access management. Direct user assignments | |
| 322 | bypass group-based controls and are not subject to standard group membership | |
| 323 | reviews. | |
| 324 | ||
| 325 | *Risk:* Direct user assignments increase the likelihood of orphaned access | |
| 326 | following role changes or departures. They are also harder to identify during | |
| 327 | periodic access reviews, as reviewers may focus on group membership rather than | |
| 328 | individual assignments. | |
| 329 | ||
| 330 | *Evidence:* Attach the relevant portion of ~report_cmc.json~ filtered to show the | |
| 331 | specific principal, permission set, and policies. The JSON output is | |
| 332 | self-documenting, as it includes the account name in the filename and contains | |
| 333 | the full policy details alongside the assignment. | |
| 334 | ||
| 335 | To filter the output to just the direct user assignments for the evidence | |
| 336 | attachment: | |
| 294 | - *Deficiency:* User ~iamtestuser1~ is directly assigned the ~AdministratorAccess~ | |
| 295 | permission set in account ~cmc~, rather than receiving access through a group | |
| 296 | assignment. | |
| 297 | - *Root Cause:* Due to {{ root cuase }}, the user ~iamtestuser1~ was inappropriately | |
| 298 | provisioned the ~AdministratorAccess~ permission directly rather than through a | |
| 299 | group assignment. | |
| 300 | - *Risk:* Direct user assignments increase the likelihood of orphaned access | |
| 301 | following role changes or departures. They are also harder to identify during | |
| 302 | periodic access reviews, as reviewers may focus on group membership rather | |
| 303 | than individual assignments. | |
| 304 | - *Evidence:* Attach the relevant portion of ~report_cmc.json~ filtered to show the | |
| 305 | specific principal, permission set, and policies. The JSON output is | |
| 306 | self-documenting, as it includes the account name in the filename and contains | |
| 307 | the full policy details alongside the assignment. | |
| 308 | ||
| 309 | If requested during review, you can filter the output to just the direct user | |
| 310 | assignments: | |
| 337 | 311 | |
| 338 | 312 | #+begin_src bash |
| 339 | 313 | jq '[.[] | select(.principal.type == "USER")]' report_cmc.json |
content/blog/2026-02-21-auditing-aws-passwords.org added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | #+date: [2026-02-21 Sat 21:13:45] | |
| 2 | #+title: Auditind AWS Passwords | |
| 3 | #+description: Learn how to audit passwords in AWS. | |
| 4 | #+slug: auditing-aws-passwords | |
| 5 | ||
| 6 | One of the first controls an IT Auditor learns is how to audit passwords in any | |
| 7 | number of IT systems. However, things have changed with the introduction of | |
| 8 | cloud platforms. This post covers the process and results of auditing AWS | |
| 9 | passwords. | |
| 10 | ||
| 11 | The scripts are available at [[https://github.com/audit-labs/audit-tools/tree/main/applications/aws/aws_password_policy][audit-labs/audit-tools]]. | |
| 12 | ||
| 13 | * Scoping | |
| 14 | ||
| 15 | First thing's first: scoping. To audit AWS passwords, we need to understand that | |
| 16 | AWS IAM password policies only apply to users with console access. This isn't | |
| 17 | the same as the password policy for an application built on top of AWS. It also | |
| 18 | has no effect on users who authenticate through IAM Identity Center, which | |
| 19 | delegates authentication to an external identity provider, such as Okta or | |
| 20 | Active Directory. | |
| 21 | ||
| 22 | If the organization uses Identity Center exclusively and has no IAM users with | |
| 23 | passwords, you will need to perform a different procedure. | |
| 24 | ||
| 25 | If they do have IAM users with console access, the script applies and is worth | |
| 26 | testing. | |
| 27 | ||
| 28 | * What the Scripts Do | |
| 29 | ||
| 30 | The process runs in two steps: | |
| 31 | 1. ~gather_policy.sh~ calls the AWS CLI to fetch the current IAM password policy, | |
| 32 | captures metadata (timestamp, AWS account, region, caller identity), and writes | |
| 33 | everything to a JSON file. This file is evidence of the policy's current state. | |
| 34 | 2. ~evaluate_policy.py~ reads that JSON file, prompts you for the expected value of | |
| 35 | each setting, and exports a CSV report with a value of ~PASS~ or ~FAIL~ for each | |
| 36 | rule. | |
| 37 | ||
| 38 | * Prerequisites | |
| 39 | ||
| 40 | To run this script, you'll need: | |
| 41 | ||
| 42 | - Access to CloudShell or the AWS CLI utility installed and configured with | |
| 43 | credentials that have read access to ~iam:GetAccountPasswordPolicy~ and | |
| 44 | ~sts:GetCallerIdentity~ | |
| 45 | - ~jq~ installed (used by the Bash script to merge JSON objects) | |
| 46 | - Python 3 installed | |
| 47 | - Optional: ~uv~ installed (used to run the Python script) | |
| 48 | ||
| 49 | * Step 1: Gather the Policy | |
| 50 | ||
| 51 | Run the first script to pull the current policy from AWS: | |
| 52 | ||
| 53 | #+begin_src bash | |
| 54 | chmod +x gather_policy.sh | |
| 55 | ./gather_policy.sh | |
| 56 | #+end_src | |
| 57 | ||
| 58 | By default, this writes the output to ~policy_report.json~ in the current | |
| 59 | directory. You can specify a custom path with the ~-o~ flag: | |
| 60 | ||
| 61 | #+begin_src bash | |
| 62 | ./gather_policy.sh -o /tmp/my_report.json | |
| 63 | #+end_src | |
| 64 | ||
| 65 | The output is a JSON file with two top-level keys: ~metadata~ and ~PasswordPolicy~. | |
| 66 | ||
| 67 | #+begin_src json | |
| 68 | { | |
| 69 | "metadata": { | |
| 70 | "report_timestamp_utc": "2025-12-15T01:29:52Z", | |
| 71 | "os_user": "cloudshell-user", | |
| 72 | "hostname": "", | |
| 73 | "working_directory": "/home/cloudshell-user", | |
| 74 | "aws_profile": "default", | |
| 75 | "aws_region": "eu-west-1", | |
| 76 | "aws_caller_identity": { | |
| 77 | "UserId": "214941490075", | |
| 78 | "Account": "214941490075", | |
| 79 | "Arn": "arn:aws:iam::214941490075:root" | |
| 80 | } | |
| 81 | }, | |
| 82 | "PasswordPolicy": { | |
| 83 | "MinimumPasswordLength": 8, | |
| 84 | "RequireSymbols": true, | |
| 85 | "RequireNumbers": true, | |
| 86 | "RequireUppercaseCharacters": true, | |
| 87 | "RequireLowercaseCharacters": true, | |
| 88 | "AllowUsersToChangePassword": true, | |
| 89 | "ExpirePasswords": true, | |
| 90 | "MaxPasswordAge": 90, | |
| 91 | "PasswordReusePrevention": 4, | |
| 92 | "HardExpiry": false | |
| 93 | } | |
| 94 | } | |
| 95 | #+end_src | |
| 96 | ||
| 97 | The metadata block is what ties this evidence to a specific account and point in | |
| 98 | time. The ~aws_caller_identity~ field shows who ran the script and in which | |
| 99 | account. | |
| 100 | ||
| 101 | * Step 2: Evaluate the Policy | |
| 102 | ||
| 103 | Pass the JSON file to the evaluation script: | |
| 104 | ||
| 105 | #+begin_src bash | |
| 106 | uv run evaluate_policy.py policy_report.json | |
| 107 | #+end_src | |
| 108 | ||
| 109 | The script will prompt you for each of the ten settings. Press ~Enter~ to skip any | |
| 110 | setting you don't need to test. For numeric settings like ~MinimumPasswordLength~ | |
| 111 | and ~MaxPasswordAge~, the script treats your input as a minimum, so the actual | |
| 112 | value must be greater than or equal to your expected value to pass. Boolean | |
| 113 | settings require an exact match. | |
| 114 | ||
| 115 | #+begin_src text | |
| 116 | === Expected / Minimum Values (press <Enter> for N/A) === | |
| 117 | ||
| 118 | Enter expected value for 'Minimum password length' (int) or press <Enter> to skip: 8 | |
| 119 | Enter expected value for 'Require symbols (!@#$...)' (bool) or press <Enter> to skip: true | |
| 120 | Enter expected value for 'Require numbers (0-9)' (bool) or press <Enter> to skip: true | |
| 121 | Enter expected value for 'Require uppercase letters (A-Z)' (bool) or press <Enter> to skip: true | |
| 122 | Enter expected value for 'Require lowercase letters (a-z)' (bool) or press <Enter> to skip: true | |
| 123 | Enter expected value for 'Allow users to change password' (bool) or press <Enter> to skip: true | |
| 124 | Enter expected value for 'Expire passwords (enable aging)' (bool) or press <Enter> to skip: true | |
| 125 | Enter expected value for 'Maximum password age (days)' (int) or press <Enter> to skip: 90 | |
| 126 | Enter expected value for 'Prevent password reuse (last N)' (int) or press <Enter> to skip: 4 | |
| 127 | Enter expected value for 'Hard expiry (no grace period)' (bool) or press <Enter> to skip: false | |
| 128 | ||
| 129 | Audit CSV written to: policy_audit_20251215T014323Z.csv | |
| 130 | ||
| 131 | Summary: | |
| 132 | 1. Minimum password length -> PASS | |
| 133 | 2. Require symbols (!@#$...) -> PASS | |
| 134 | 3. Require numbers (0-9) -> PASS | |
| 135 | 4. Require uppercase letters (A-Z) -> PASS | |
| 136 | 5. Require lowercase letters (a-z) -> PASS | |
| 137 | 6. Allow users to change password -> PASS | |
| 138 | 7. Expire passwords (enable aging) -> PASS | |
| 139 | 8. Maximum password age (days) -> PASS | |
| 140 | 9. Prevent password reuse (last N) -> PASS | |
| 141 | 10. Hard expiry (no grace period) -> PASS | |
| 142 | ||
| 143 | --- End of report --- | |
| 144 | #+end_src | |
| 145 | ||
| 146 | * Reading the CSV | |
| 147 | ||
| 148 | The CSV is your evidence. It includes the metadata header from the JSON file, so | |
| 149 | the account ID, timestamp, and caller identity are embedded directly in the | |
| 150 | file. | |
| 151 | ||
| 152 | #+begin_src csv | |
| 153 | # report_timestamp_utc: 2025-12-15T01:29:52Z | |
| 154 | # os_user: cloudshell-user | |
| 155 | # hostname: | |
| 156 | # working_directory: /home/cloudshell-user | |
| 157 | # aws_profile: default | |
| 158 | # aws_region: eu-west-1 | |
| 159 | # aws_caller_identity: {'UserId': '214941490075', 'Account': '214941490075', 'Arn': 'arn:aws:iam::214941490075:root'} | |
| 160 | ||
| 161 | Rule#,Policy-Item,Expected,Actual,Result | |
| 162 | 1,Minimum password length,8,8,PASS | |
| 163 | 2,Require symbols (!@#$...),true,true,PASS | |
| 164 | 3,Require numbers (0-9),true,true,PASS | |
| 165 | 4,Require uppercase letters (A-Z),true,true,PASS | |
| 166 | 5,Require lowercase letters (a-z),true,true,PASS | |
| 167 | 6,Allow users to change password,true,true,PASS | |
| 168 | 7,Expire passwords (enable aging),true,true,PASS | |
| 169 | 8,Maximum password age (days),90,90,PASS | |
| 170 | 9,Prevent password reuse (last N),4,4,PASS | |
| 171 | 10,Hard expiry (no grace period),false,false,PASS | |
| 172 | #+end_src | |
| 173 | ||
| 174 | You may use this evidence in any form, but I suggest having your AWS contact | |
| 175 | screenshot the results directly within their CloudShell or AWS CLI session. This | |
| 176 | allows you to prove that the data was not modified after the script was run. | |
| 177 | ||
| 178 | * Common Exceptions and False Positives | |
| 179 | ||
| 180 | - *No policy defined*: If ~gather_policy.sh~ exits with a ~NoSuchEntity~ error, the | |
| 181 | account has no IAM password policy configured. If you were expecting a password | |
| 182 | policy, document it as a missing control. | |
| 183 | - *HardExpiry: false*: This setting controls whether users are locked out | |
| 184 | immediately when their password expires or given a grace period to change it. | |
| 185 | ~false~ is often intentional to avoid lockouts. Check the organization's policy | |
| 186 | before calling it a finding. Additionally, check if the organization has | |
| 187 | security exceptions in place before noting a deficiency. | |
| 188 | - *MaxPasswordAge and forced rotation*: A 90-day rotation requirement is common in | |
| 189 | older policies and frameworks like CIS. NIST 800-63B no longer recommends forced | |
| 190 | rotation unless there's evidence of compromise. Know which framework you're | |
| 191 | auditing against before writing up a finding for this setting. Confirm with the | |
| 192 | organization to understand which framework they used to write their policy. | |
| 193 | - *PasswordReusePrevention*: AWS allows a maximum of 24 previous passwords. If your | |
| 194 | organization's policy requires a higher number than AWS supports, document the | |
| 195 | platform limitation rather than raising it as a deficiency. | |
| 196 | ||
| 197 | * How to Write Up the Finding | |
| 198 | ||
| 199 | If a setting fails, here's how to frame it: | |
| 200 | - *Deficiency:* The ~MinimumPasswordLength~ setting in the AWS IAM password policy is | |
| 201 | configured to ~6~, which is below the organization's requirement of ~8~ characters. | |
| 202 | - *Root Cause:* Due to {{ root cause }}, the policy was configured to enforce a | |
| 203 | ~MinimumPasswordLength~ of ~6~. | |
| 204 | - *Risk:* Shorter passwords are more susceptible to brute-force and credential | |
| 205 | stuffing attacks, increasing the likelihood of unauthorized access to the AWS | |
| 206 | console. | |
| 207 | - *Evidence:* Refer to ~policy_audit_<timestamp>.csv~ for documentation of testing. | |
| 208 | ||
| 209 | The same structure applies to any other failing rule. For boolean settings, the | |
| 210 | deficiency is simply that the actual value does not match the expected value. | |
| 211 | For numeric settings, the deficiency is that the actual value falls below the | |
| 212 | required minimum. | |