plexavo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plexavo/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Plexavo — open-source AWS misconfiguration scanner.
2
+
3
+ Runs entirely with your own local AWS credentials. Nothing is sent to
4
+ anyone else. See README.md for usage, or `plexavo scan --help`.
5
+ """
6
+
7
+ __version__ = "0.1.0"
plexavo/auth.py ADDED
@@ -0,0 +1,61 @@
1
+ """Local AWS credential handling.
2
+
3
+ Design constraint: nothing is ever handed to anyone else. Plexavo uses
4
+ the caller's own configured credentials directly — the default profile,
5
+ a named profile, or environment variables — exactly the same resolution
6
+ order the AWS CLI itself uses. There is no cross-account access, no
7
+ CloudFormation role, and no server in between. Run it yourself, with
8
+ your own credentials, and nothing leaves your machine.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import boto3
14
+ from botocore.exceptions import NoCredentialsError, ProfileNotFound
15
+
16
+
17
+ def get_local_session(profile_name: str | None = None, region: str | None = None) -> boto3.Session:
18
+ """Build a session from the caller's own local credentials.
19
+
20
+ Raises RuntimeError with a clear message on failure — auth failures
21
+ should stop the scan immediately, not surface as a confusing
22
+ downstream permission error buried inside an individual check.
23
+ """
24
+ try:
25
+ session = boto3.Session(profile_name=profile_name)
26
+ except ProfileNotFound as e:
27
+ raise RuntimeError(
28
+ f"{e}. Run `aws configure list-profiles` to see what's "
29
+ "available, or `aws configure` to set up the default profile."
30
+ ) from e
31
+
32
+ resolved_region = region or session.region_name
33
+ if not resolved_region:
34
+ raise RuntimeError(
35
+ "No AWS region configured. Run `aws configure` to set a "
36
+ "default region, or pass --region explicitly."
37
+ )
38
+
39
+ # boto3.Session() doesn't fail on missing credentials until you
40
+ # actually make a call — force that check now, with a clear message,
41
+ # instead of letting it surface later as an opaque error from deep
42
+ # inside the first check that happens to run.
43
+ try:
44
+ boto3.Session(
45
+ profile_name=profile_name, region_name=resolved_region
46
+ ).client("sts").get_caller_identity()
47
+ except NoCredentialsError as e:
48
+ raise RuntimeError(
49
+ "No AWS credentials found. Run `aws configure` "
50
+ f"{f'--profile {profile_name} ' if profile_name else ''}"
51
+ "to set them up."
52
+ ) from e
53
+
54
+ return boto3.Session(profile_name=profile_name, region_name=resolved_region)
55
+
56
+
57
+ def get_account_id(session: boto3.Session) -> str:
58
+ """Confirm which account we actually landed in — always verify this
59
+ before running checks, so a misconfigured profile doesn't silently
60
+ scan the wrong account."""
61
+ return session.client("sts").get_caller_identity()["Account"]
File without changes
@@ -0,0 +1,117 @@
1
+ """Category 6: Encryption and Data Protection — checks 29-31, all Medium.
2
+
3
+ check_29 (EBS) and check_30 (RDS) are straightforward flag-checks, no
4
+ surprises expected.
5
+
6
+ check_31 (S3 default encryption) carries the SAME risk that broke
7
+ STOR-19's original "missing PAB" ground truth, flagged proactively this
8
+ time instead of discovered by a failed apply: AWS made SSE-S3 the
9
+ mandatory, automatic default for every S3 bucket in January 2023,
10
+ applied irrevocably (you can only upgrade to SSE-KMS, never disable
11
+ encryption entirely). A bucket created today will very likely ALWAYS
12
+ have at least a default AES256 configuration, meaning
13
+ ServerSideEncryptionConfigurationNotFoundError may be unreachable for
14
+ any bucket created post-January-2023. The check itself is still correct
15
+ and valuable — for real customer accounts with buckets that predate
16
+ this change and were never touched since — it's just probably not
17
+ something we can ground-truth with a freshly created bucket. We'll know
18
+ for certain once Terraform is applied; not assuming either way.
19
+
20
+ Confirmed via the real S3 service model before writing this (not
21
+ guessed): neither ServerSideEncryptionConfigurationNotFoundError here,
22
+ nor the two S3 error codes storage.py hit, are modeled as distinct
23
+ exception classes. Generic ClientError + error-code inspection used from
24
+ the start this time.
25
+ """
26
+
27
+ from botocore.exceptions import ClientError
28
+
29
+ from plexavo.findings import Finding, Severity
30
+
31
+
32
+ def check_29_unencrypted_ebs_volumes(ec2) -> list[Finding]:
33
+ """ENC-29: EBS volume with Encrypted=False."""
34
+ findings = []
35
+ paginator = ec2.get_paginator("describe_volumes")
36
+ for page in paginator.paginate():
37
+ for vol in page["Volumes"]:
38
+ if vol.get("Encrypted"):
39
+ continue
40
+ vol_id = vol["VolumeId"]
41
+ attachments = vol.get("Attachments", [])
42
+ attached_to = (
43
+ ", ".join(a["InstanceId"] for a in attachments)
44
+ if attachments else "not attached to any instance"
45
+ )
46
+ findings.append(Finding(
47
+ check_id="ENC-29",
48
+ title="Unencrypted EBS Volume",
49
+ severity=Severity.MEDIUM,
50
+ resource_arn=vol_id,
51
+ raw_detail=f"EBS volume '{vol_id}' ({attached_to}) is not encrypted. Data "
52
+ f"at rest on this volume — and any snapshot taken from it — is "
53
+ f"stored in plaintext.",
54
+ account_context=f"attached_to={attached_to}",
55
+ ))
56
+ return findings
57
+
58
+
59
+ def check_30_unencrypted_rds_instances(rds) -> list[Finding]:
60
+ """ENC-30: RDS instance with StorageEncrypted=False."""
61
+ findings = []
62
+ paginator = rds.get_paginator("describe_db_instances")
63
+ for page in paginator.paginate():
64
+ for db in page["DBInstances"]:
65
+ if db.get("StorageEncrypted"):
66
+ continue
67
+ db_id = db["DBInstanceIdentifier"]
68
+ engine = db.get("Engine", "unknown")
69
+ findings.append(Finding(
70
+ check_id="ENC-30",
71
+ title="Unencrypted RDS Instance",
72
+ severity=Severity.MEDIUM,
73
+ resource_arn=db.get("DBInstanceArn", db_id),
74
+ raw_detail=f"RDS instance '{db_id}' ({engine}) has StorageEncrypted=False. "
75
+ f"Data at rest — the database files, automated backups, and "
76
+ f"snapshots — is stored unencrypted.",
77
+ account_context=f"engine={engine}",
78
+ ))
79
+ return findings
80
+
81
+
82
+ def check_31_s3_missing_default_encryption(s3, bucket_names: list) -> list[Finding]:
83
+ """ENC-31: bucket has no default server-side encryption configuration.
84
+ See module docstring on why this may rarely fire on modern buckets."""
85
+ findings = []
86
+ for name in bucket_names:
87
+ try:
88
+ s3.get_bucket_encryption(Bucket=name)
89
+ continue # has some encryption config, whatever it is
90
+ except ClientError as e:
91
+ if e.response["Error"]["Code"] != "ServerSideEncryptionConfigurationNotFoundError":
92
+ raise
93
+ findings.append(Finding(
94
+ check_id="ENC-31",
95
+ title="S3 Bucket Without Default Encryption",
96
+ severity=Severity.MEDIUM,
97
+ resource_arn=f"arn:aws:s3:::{name}",
98
+ raw_detail=f"Bucket '{name}' has no default server-side encryption "
99
+ f"configuration. Objects uploaded without an explicit "
100
+ f"encryption header are stored in plaintext.",
101
+ account_context=f"bucket={name}",
102
+ ))
103
+ return findings
104
+
105
+
106
+ def run_all(session) -> list[Finding]:
107
+ """Run ENC-29 through ENC-31 against every relevant resource in the account."""
108
+ ec2 = session.client("ec2")
109
+ rds = session.client("rds")
110
+ s3 = session.client("s3")
111
+ bucket_names = [b["Name"] for b in s3.list_buckets()["Buckets"]]
112
+
113
+ findings = []
114
+ findings += check_29_unencrypted_ebs_volumes(ec2)
115
+ findings += check_30_unencrypted_rds_instances(rds)
116
+ findings += check_31_s3_missing_default_encryption(s3, bucket_names)
117
+ return findings
plexavo/checks/iam.py ADDED
@@ -0,0 +1,459 @@
1
+ """Category 1: IAM Privilege Escalation Paths — checks 1-6 (Critical severity).
2
+
3
+ This is the core differentiator. Each function takes the full list of
4
+ Principal objects (already loaded with policies + group-inherited
5
+ policies + permission boundary via principals.py) and returns a list of
6
+ Finding objects.
7
+
8
+ IMPORTANT on false positives: check_04 (PassRole+Compute) and check_05
9
+ (AssumeRole Chain) both verify the prerequisite actually exists before
10
+ flagging. check_02/03/04/05/06 additionally verify the relevant action
11
+ isn't blocked by an explicit Deny (identity policy or permission
12
+ boundary) before flagging. These verification steps are the exact gap
13
+ the blueprint calls out in PMapper/CloudSplaining — don't strip them out
14
+ to "simplify" later.
15
+
16
+ v3 changes (Deny statements + permission boundaries):
17
+ - Every check now calls _apply_deny_and_boundary() before finalizing a finding: an
18
+ unconditioned matching Deny suppresses the finding entirely (it's not
19
+ actually exploitable); a conditioned Deny downgrades Critical to High
20
+ with a note, since we can't be certain the condition always applies.
21
+ - check_01 (wildcard admin) only suppresses on a FULL wildcard Deny
22
+ (Action:*/Resource:* or NotAction/NotResource equivalent) — partial
23
+ per-service Denies don't suppress it. Stated limitation, not an
24
+ oversight: resolving exactly what remains after a partial Deny needs
25
+ real policy simulation, out of MVP scope.
26
+ - is_admin_equivalent() now returns False for any principal with a
27
+ permission boundary that ISN'T itself a wildcard grant, regardless of
28
+ what the identity policy says — a non-wildcard boundary caps effective
29
+ permissions below admin. Also returns False if has_full_wildcard_deny()
30
+ is true, even with AdministratorAccess attached (a real break-glass
31
+ pattern: broad Allow + full Deny).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from plexavo.findings import Finding, Severity
37
+ from plexavo.principals import (
38
+ Principal,
39
+ statement_grants,
40
+ statement_has_wildcard_action,
41
+ resource_is_wildcard,
42
+ resource_includes,
43
+ find_blocking_deny,
44
+ has_full_wildcard_deny,
45
+ action_within_boundary,
46
+ _normalize,
47
+ )
48
+
49
+ SELF_ESCALATION_ACTIONS = {
50
+ "iam:AttachUserPolicy",
51
+ "iam:PutUserPolicy",
52
+ "iam:AttachRolePolicy",
53
+ "iam:PutRolePolicy",
54
+ "iam:AttachGroupPolicy",
55
+ "iam:PutGroupPolicy",
56
+ "iam:AddUserToGroup",
57
+ "iam:CreateAccessKey",
58
+ "iam:CreateLoginProfile",
59
+ "iam:UpdateLoginProfile",
60
+ }
61
+
62
+ COMPUTE_LAUNCH_ACTIONS = {
63
+ "ec2:RunInstances",
64
+ "lambda:CreateFunction",
65
+ "lambda:UpdateFunctionConfiguration",
66
+ "ecs:RunTask",
67
+ "ecs:CreateService",
68
+ "glue:CreateJob",
69
+ "sagemaker:CreateNotebookInstance",
70
+ "apprunner:CreateService",
71
+ }
72
+
73
+ COMPUTE_SERVICE_PRINCIPALS = {
74
+ "ec2.amazonaws.com",
75
+ "lambda.amazonaws.com",
76
+ "ecs-tasks.amazonaws.com",
77
+ "glue.amazonaws.com",
78
+ "sagemaker.amazonaws.com",
79
+ "apprunner.amazonaws.com",
80
+ }
81
+
82
+ BROAD_OTHER_SERVICE_WILDCARDS = {"ec2:*", "s3:*", "rds:*", "lambda:*", "dynamodb:*"}
83
+
84
+
85
+ def _all_statements(principal: Principal):
86
+ for policy_name, statements in principal.policies:
87
+ for stmt in statements:
88
+ yield policy_name, stmt
89
+
90
+
91
+ def _condition_adjustment(stmt: dict) -> tuple[Severity, str]:
92
+ """A Condition block on the ALLOW statement itself means we can't
93
+ safely claim this grant is unconditional. Downgrade Critical -> High
94
+ and say so, rather than suppressing or overclaiming."""
95
+ condition = stmt.get("Condition")
96
+ if condition:
97
+ keys = ", ".join(condition.keys())
98
+ return (
99
+ Severity.HIGH,
100
+ f" NOTE: this grant is scoped by a Condition block ({keys}) — "
101
+ f"not evaluated automatically; verify manually whether it "
102
+ f"meaningfully restricts access.",
103
+ )
104
+ return Severity.CRITICAL, ""
105
+
106
+
107
+ def _apply_deny_and_boundary(principal: Principal, action: str, resource_arn: str | None,
108
+ severity: Severity, note: str):
109
+ """Combined evaluator: explicit Deny (identity + boundary) can suppress
110
+ or downgrade; a permission boundary that doesn't allow this action caps
111
+ it away entirely (implicit deny by omission — not a Condition-style
112
+ uncertainty, so this always suppresses rather than downgrades).
113
+ Returns (severity, note) or None if the finding should be suppressed."""
114
+ blocked, is_conditioned = find_blocking_deny(principal, action, resource_arn)
115
+ if blocked and not is_conditioned:
116
+ return None
117
+ if blocked and is_conditioned:
118
+ severity = Severity.HIGH if severity == Severity.CRITICAL else severity
119
+ note = note + (
120
+ " Also potentially blocked by a conditioned Deny elsewhere in "
121
+ "this principal's policies — verify manually."
122
+ )
123
+ if not action_within_boundary(principal, action, resource_arn):
124
+ return None
125
+ return severity, note
126
+
127
+
128
+ def _principal_has_wildcard_grant(principal: Principal) -> bool:
129
+ return any(
130
+ stmt.get("Effect") == "Allow"
131
+ and statement_has_wildcard_action(stmt)
132
+ and resource_is_wildcard(stmt)
133
+ for _, statements in principal.policies
134
+ for stmt in statements
135
+ )
136
+
137
+
138
+ def is_admin_equivalent(principal: Principal) -> bool:
139
+ """True if this principal is 'admin-equivalent' for escalation-target
140
+ purposes:
141
+ - False immediately if a full wildcard Deny exists (break-glass
142
+ pattern: broad Allow neutered by an equally broad Deny).
143
+ - False if a permission boundary is attached and it ISN'T itself a
144
+ wildcard grant — a non-wildcard boundary caps real permissions
145
+ below admin regardless of the identity policy.
146
+ - Otherwise True if there's a literal Action:*/Resource:* grant, OR
147
+ iam:* combined with broad wildcards on 2+ other core services
148
+ (heuristic for functionally-admin custom policies).
149
+ This is a heuristic, not full policy simulation — see principals.py
150
+ docstring for the exact limitation (boundary Allow-sets aren't used
151
+ to compute a true intersection, only boundary Deny statements and
152
+ "is the boundary itself wildcard" are considered).
153
+ """
154
+ if has_full_wildcard_deny(principal):
155
+ return False
156
+
157
+ if principal.has_permission_boundary and not action_within_boundary(principal, "*", None):
158
+ return False
159
+
160
+ if _principal_has_wildcard_grant(principal):
161
+ return True
162
+
163
+ granted_iam_star = False
164
+ broad_other_services = set()
165
+ for _policy_name, statements in principal.policies:
166
+ for stmt in statements:
167
+ if stmt.get("Effect") != "Allow" or "Action" not in stmt:
168
+ continue
169
+ for a in _normalize(stmt.get("Action")):
170
+ al = a.lower()
171
+ if al == "iam:*":
172
+ granted_iam_star = True
173
+ elif al in BROAD_OTHER_SERVICE_WILDCARDS:
174
+ broad_other_services.add(al)
175
+ return granted_iam_star and len(broad_other_services) >= 2
176
+
177
+
178
+ def check_01_wildcard_admin(principals: list[Principal]) -> list[Finding]:
179
+ """IAM-01: Any principal with effectively Action:* on effectively Resource:*,
180
+ unless neutered by a Deny or capped below wildcard by a permission boundary."""
181
+ findings = []
182
+ for p in principals:
183
+ for policy_name, stmt in _all_statements(p):
184
+ if stmt.get("Effect") != "Allow":
185
+ continue
186
+ if statement_has_wildcard_action(stmt) and resource_is_wildcard(stmt):
187
+ severity, note = _condition_adjustment(stmt)
188
+ result = _apply_deny_and_boundary(p, "*", None, severity, note)
189
+ if result is None:
190
+ continue
191
+ severity, note = result
192
+ findings.append(Finding(
193
+ check_id="IAM-01",
194
+ title="Wildcard Admin Access",
195
+ severity=severity,
196
+ resource_arn=p.arn,
197
+ raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
198
+ f"effectively unrestricted access on all resources "
199
+ f"(full administrator access).{note}",
200
+ account_context=f"policy={policy_name}",
201
+ ))
202
+ return findings
203
+
204
+
205
+ def check_02_self_escalation(principals: list[Principal]) -> list[Finding]:
206
+ """IAM-02: Principal can attach/put policies, join groups, or create
207
+ credentials on a resource scope that includes itself."""
208
+ findings = []
209
+ for p in principals:
210
+ for policy_name, stmt in _all_statements(p):
211
+ if stmt.get("Effect") != "Allow":
212
+ continue
213
+ granted = statement_grants(stmt, SELF_ESCALATION_ACTIONS)
214
+ if not granted:
215
+ continue
216
+ if not (resource_is_wildcard(stmt) or resource_includes(stmt, p.arn)):
217
+ continue
218
+
219
+ target_resource = None if resource_is_wildcard(stmt) else p.arn
220
+ severity, note = _condition_adjustment(stmt)
221
+ remaining = set()
222
+ any_conditioned_deny = False
223
+ for action in granted:
224
+ result = _apply_deny_and_boundary(p, action, target_resource, severity, note)
225
+ if result is None:
226
+ continue # this specific action is unconditionally blocked
227
+ action_severity, _ = result
228
+ if action_severity != severity:
229
+ any_conditioned_deny = True
230
+ remaining.add(action)
231
+ if not remaining:
232
+ continue # every granted action was unconditionally denied
233
+
234
+ if any_conditioned_deny:
235
+ severity = Severity.HIGH if severity == Severity.CRITICAL else severity
236
+ note += " Some of this access may additionally be blocked by a conditioned Deny — verify manually."
237
+
238
+ findings.append(Finding(
239
+ check_id="IAM-02",
240
+ title="IAM Self-Escalation",
241
+ severity=severity,
242
+ resource_arn=p.arn,
243
+ raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
244
+ f"{', '.join(sorted(remaining))} on a resource scope that "
245
+ f"includes itself — this principal can grant itself any "
246
+ f"additional permission or credential.{note}",
247
+ account_context=f"policy={policy_name}",
248
+ ))
249
+ return findings
250
+
251
+
252
+ def check_03_create_policy_version(principals: list[Principal], all_managed_policy_owners: dict) -> list[Finding]:
253
+ """IAM-03: iam:CreatePolicyVersion on a managed policy this principal doesn't own."""
254
+ findings = []
255
+ for p in principals:
256
+ for policy_name, stmt in _all_statements(p):
257
+ if stmt.get("Effect") != "Allow":
258
+ continue
259
+ granted = statement_grants(stmt, {"iam:CreatePolicyVersion"})
260
+ if not granted:
261
+ continue
262
+ severity, note = _condition_adjustment(stmt)
263
+
264
+ if resource_is_wildcard(stmt):
265
+ result = _apply_deny_and_boundary(p, "iam:CreatePolicyVersion", None, severity, note)
266
+ if result is None:
267
+ continue
268
+ severity, note = result
269
+ findings.append(Finding(
270
+ check_id="IAM-03",
271
+ title="CreatePolicyVersion Escalation",
272
+ severity=severity,
273
+ resource_arn=p.arn,
274
+ raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
275
+ f"iam:CreatePolicyVersion on all policies. This principal "
276
+ f"can rewrite any managed policy in the account to grant "
277
+ f"itself full access and set it as the default version.{note}",
278
+ account_context=f"policy={policy_name}",
279
+ ))
280
+ else:
281
+ for r in _normalize(stmt.get("Resource")):
282
+ owner = all_managed_policy_owners.get(r)
283
+ if owner is None or owner == p.arn:
284
+ continue
285
+ result = _apply_deny_and_boundary(p, "iam:CreatePolicyVersion", r, severity, note)
286
+ if result is None:
287
+ continue
288
+ r_severity, r_note = result
289
+ findings.append(Finding(
290
+ check_id="IAM-03",
291
+ title="CreatePolicyVersion Escalation",
292
+ severity=r_severity,
293
+ resource_arn=p.arn,
294
+ raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' "
295
+ f"grants iam:CreatePolicyVersion on '{r}', a policy "
296
+ f"it doesn't own. It can rewrite that policy to grant "
297
+ f"broader access.{r_note}",
298
+ account_context=f"policy={policy_name}",
299
+ ))
300
+ return findings
301
+
302
+
303
+ def check_04_passrole_compute(principals: list[Principal]) -> list[Finding]:
304
+ """IAM-04: PassRole + compute launch, verified against a real high-priv
305
+ target role whose trust policy actually allows a compute service to
306
+ assume it, and that iam:PassRole to that specific target isn't Denied."""
307
+ findings = []
308
+
309
+ dangerous_roles = {}
310
+ for p in principals:
311
+ if p.type != "role" or not p.trust_policy:
312
+ continue
313
+ trust_statements = _normalize(p.trust_policy.get("Statement"))
314
+ trusts_compute = False
315
+ for stmt in trust_statements:
316
+ if stmt.get("Effect") != "Allow":
317
+ continue
318
+ principal_field = stmt.get("Principal", {})
319
+ services = _normalize(principal_field.get("Service")) if isinstance(principal_field, dict) else []
320
+ if any(s in COMPUTE_SERVICE_PRINCIPALS for s in services):
321
+ trusts_compute = True
322
+ break
323
+ if trusts_compute and is_admin_equivalent(p):
324
+ dangerous_roles[p.arn] = p.name
325
+
326
+ if not dangerous_roles:
327
+ return findings
328
+
329
+ for p in principals:
330
+ for policy_name, stmt in _all_statements(p):
331
+ if stmt.get("Effect") != "Allow":
332
+ continue
333
+ if not statement_grants(stmt, {"iam:PassRole"}):
334
+ continue
335
+ has_launch = any(
336
+ statement_grants(s, COMPUTE_LAUNCH_ACTIONS)
337
+ for _, s in _all_statements(p)
338
+ )
339
+ if not has_launch:
340
+ continue
341
+ reachable = {
342
+ arn: name for arn, name in dangerous_roles.items()
343
+ if resource_includes(stmt, arn)
344
+ }
345
+ base_severity, base_note = _condition_adjustment(stmt)
346
+ for role_arn, role_name in reachable.items():
347
+ if role_arn == p.arn:
348
+ continue
349
+ result = _apply_deny_and_boundary(p, "iam:PassRole", role_arn, base_severity, base_note)
350
+ if result is None:
351
+ continue
352
+ severity, note = result
353
+ findings.append(Finding(
354
+ check_id="IAM-04",
355
+ title="PassRole + Compute Privilege Escalation",
356
+ severity=severity,
357
+ resource_arn=p.arn,
358
+ raw_detail=f"{p.type} '{p.name}' has iam:PassRole (policy '{policy_name}') "
359
+ f"plus a compute-launch permission, and can pass the "
360
+ f"high-privilege role '{role_name}' ({role_arn}) — which trusts "
361
+ f"a compute service — to a new EC2 instance or Lambda function, "
362
+ f"gaining that role's permissions.{note}",
363
+ account_context=f"target_role={role_arn}",
364
+ ))
365
+ return findings
366
+
367
+
368
+ def check_05_assumerole_chain_to_admin(principals: list[Principal]) -> list[Finding]:
369
+ """IAM-05: Role A can assume Role B, and Role B has admin-level access.
370
+ One-hop only for MVP, as the blueprint specifies."""
371
+ findings = []
372
+ roles_by_arn = {p.arn: p for p in principals if p.type == "role"}
373
+ admin_roles = {p.arn for p in principals if p.type == "role" and is_admin_equivalent(p)}
374
+
375
+ for p in principals:
376
+ for policy_name, stmt in _all_statements(p):
377
+ if stmt.get("Effect") != "Allow":
378
+ continue
379
+ if not statement_grants(stmt, {"sts:AssumeRole"}):
380
+ continue
381
+ if resource_is_wildcard(stmt):
382
+ continue # that's check IAM-06, not IAM-05
383
+ base_severity, base_note = _condition_adjustment(stmt)
384
+ for target_arn in _normalize(stmt.get("Resource")):
385
+ if target_arn not in admin_roles or target_arn == p.arn:
386
+ continue
387
+ result = _apply_deny_and_boundary(p, "sts:AssumeRole", target_arn, base_severity, base_note)
388
+ if result is None:
389
+ continue
390
+ severity, note = result
391
+ target_name = roles_by_arn[target_arn].name
392
+ findings.append(Finding(
393
+ check_id="IAM-05",
394
+ title="AssumeRole Chain to Admin",
395
+ severity=severity,
396
+ resource_arn=p.arn,
397
+ raw_detail=f"{p.type} '{p.name}' (policy '{policy_name}') can call "
398
+ f"sts:AssumeRole on '{target_name}' ({target_arn}), which "
399
+ f"has administrator-equivalent access. One hop from "
400
+ f"'{p.name}' reaches full admin.{note}",
401
+ account_context=f"target_role={target_arn}",
402
+ ))
403
+ return findings
404
+
405
+
406
+ def check_06_wildcard_assumerole(principals: list[Principal]) -> list[Finding]:
407
+ """IAM-06: sts:AssumeRole on effectively Resource:* — can assume any role in the account."""
408
+ findings = []
409
+ for p in principals:
410
+ for policy_name, stmt in _all_statements(p):
411
+ if stmt.get("Effect") != "Allow":
412
+ continue
413
+ if not statement_grants(stmt, {"sts:AssumeRole"}):
414
+ continue
415
+ if not resource_is_wildcard(stmt):
416
+ continue
417
+ severity, note = _condition_adjustment(stmt)
418
+ result = _apply_deny_and_boundary(p, "sts:AssumeRole", None, severity, note)
419
+ if result is None:
420
+ continue
421
+ severity, note = result
422
+ findings.append(Finding(
423
+ check_id="IAM-06",
424
+ title="Wildcard AssumeRole",
425
+ severity=severity,
426
+ resource_arn=p.arn,
427
+ raw_detail=f"Policy '{policy_name}' on {p.type} '{p.name}' grants "
428
+ f"sts:AssumeRole on effectively all resources — this "
429
+ f"principal can assume ANY role in the account, including "
430
+ f"admin roles created after this scan.{note}",
431
+ account_context=f"policy={policy_name}",
432
+ ))
433
+ return findings
434
+
435
+
436
+ def run_all(session, principals: list[Principal]) -> list[Finding]:
437
+ """Run checks 1-6 and return the combined finding list."""
438
+ iam = session.client("iam")
439
+
440
+ policy_owners = {}
441
+ paginator = iam.get_paginator("list_policies")
442
+ for page in paginator.paginate(Scope="Local"):
443
+ for policy in page["Policies"]:
444
+ entities = iam.list_entities_for_policy(PolicyArn=policy["Arn"])
445
+ owner_arn = None
446
+ if entities["PolicyUsers"]:
447
+ owner_arn = f"arn:aws:iam::{policy['Arn'].split(':')[4]}:user/{entities['PolicyUsers'][0]['UserName']}"
448
+ elif entities["PolicyRoles"]:
449
+ owner_arn = f"arn:aws:iam::{policy['Arn'].split(':')[4]}:role/{entities['PolicyRoles'][0]['RoleName']}"
450
+ policy_owners[policy["Arn"]] = owner_arn
451
+
452
+ findings = []
453
+ findings += check_01_wildcard_admin(principals)
454
+ findings += check_02_self_escalation(principals)
455
+ findings += check_03_create_policy_version(principals, policy_owners)
456
+ findings += check_04_passrole_compute(principals)
457
+ findings += check_05_assumerole_chain_to_admin(principals)
458
+ findings += check_06_wildcard_assumerole(principals)
459
+ return findings