iam-policy-validator 1.7.2__py3-none-any.whl → 1.9.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.
Files changed (56) hide show
  1. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/METADATA +127 -6
  2. iam_policy_validator-1.9.0.dist-info/RECORD +95 -0
  3. iam_validator/__init__.py +1 -1
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/__init__.py +5 -3
  6. iam_validator/checks/action_condition_enforcement.py +559 -207
  7. iam_validator/checks/action_resource_matching.py +12 -15
  8. iam_validator/checks/action_validation.py +7 -13
  9. iam_validator/checks/condition_key_validation.py +7 -13
  10. iam_validator/checks/condition_type_mismatch.py +15 -22
  11. iam_validator/checks/full_wildcard.py +9 -13
  12. iam_validator/checks/mfa_condition_check.py +8 -17
  13. iam_validator/checks/policy_size.py +6 -39
  14. iam_validator/checks/policy_structure.py +547 -0
  15. iam_validator/checks/policy_type_validation.py +61 -46
  16. iam_validator/checks/principal_validation.py +71 -148
  17. iam_validator/checks/resource_validation.py +13 -20
  18. iam_validator/checks/sensitive_action.py +15 -18
  19. iam_validator/checks/service_wildcard.py +8 -14
  20. iam_validator/checks/set_operator_validation.py +21 -28
  21. iam_validator/checks/sid_uniqueness.py +16 -42
  22. iam_validator/checks/trust_policy_validation.py +506 -0
  23. iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
  24. iam_validator/checks/utils/wildcard_expansion.py +2 -2
  25. iam_validator/checks/wildcard_action.py +9 -13
  26. iam_validator/checks/wildcard_resource.py +9 -13
  27. iam_validator/commands/cache.py +4 -3
  28. iam_validator/commands/validate.py +15 -9
  29. iam_validator/core/__init__.py +2 -3
  30. iam_validator/core/access_analyzer.py +1 -1
  31. iam_validator/core/access_analyzer_report.py +2 -2
  32. iam_validator/core/aws_fetcher.py +24 -1028
  33. iam_validator/core/aws_service/__init__.py +21 -0
  34. iam_validator/core/aws_service/cache.py +108 -0
  35. iam_validator/core/aws_service/client.py +205 -0
  36. iam_validator/core/aws_service/fetcher.py +612 -0
  37. iam_validator/core/aws_service/parsers.py +149 -0
  38. iam_validator/core/aws_service/patterns.py +51 -0
  39. iam_validator/core/aws_service/storage.py +291 -0
  40. iam_validator/core/aws_service/validators.py +379 -0
  41. iam_validator/core/check_registry.py +165 -93
  42. iam_validator/core/config/condition_requirements.py +69 -17
  43. iam_validator/core/config/defaults.py +58 -52
  44. iam_validator/core/config/service_principals.py +40 -3
  45. iam_validator/core/constants.py +17 -0
  46. iam_validator/core/ignore_patterns.py +297 -0
  47. iam_validator/core/models.py +15 -5
  48. iam_validator/core/policy_checks.py +38 -475
  49. iam_validator/core/policy_loader.py +27 -4
  50. iam_validator/sdk/__init__.py +1 -1
  51. iam_validator/sdk/context.py +1 -1
  52. iam_validator/sdk/helpers.py +1 -1
  53. iam_policy_validator-1.7.2.dist-info/RECORD +0 -84
  54. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/WHEEL +0 -0
  55. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/entry_points.txt +0 -0
  56. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,23 @@
1
1
  """
2
- Action-Specific Condition Enforcement Check (Unified)
2
+ Action-Specific Condition Enforcement Check
3
3
 
4
- This built-in check ensures that specific actions have required conditions.
4
+ This check ensures that specific actions have required conditions.
5
5
  Supports ALL types of conditions: MFA, IP, VPC, time, tags, encryption, etc.
6
6
 
7
- Supports advanced "all_of" and "any_of" logic for both actions and conditions.
8
- Supports both STATEMENT-LEVEL and POLICY-LEVEL enforcement.
7
+ The entire policy is scanned once, checking all statements for matching actions.
8
+
9
+ ACTION MATCHING MODES:
10
+ - Simple list: Checks each statement for any of the specified actions
11
+ Example: actions: ["iam:PassRole", "iam:CreateUser"]
12
+
13
+ - any_of: Finds statements that contain ANY of the specified actions
14
+ Example: actions: {any_of: ["iam:CreateUser", "iam:AttachUserPolicy"]}
15
+
16
+ - all_of: Finds statements that contain ALL specified actions (overly permissive detection)
17
+ Example: actions: {all_of: ["iam:CreateAccessKey", "iam:UpdateAccessKey"]}
18
+
19
+ - none_of: Flags statements that contain forbidden actions
20
+ Example: actions: {none_of: ["iam:DeleteUser", "s3:DeleteBucket"]}
9
21
 
10
22
  Common use cases:
11
23
  - iam:PassRole must have iam:PassedToService condition
@@ -13,17 +25,17 @@ Common use cases:
13
25
  - Actions must have source IP restrictions
14
26
  - Resources must have required tags
15
27
  - Combine multiple conditions (MFA + IP + Tags)
16
- - Policy-level: Ensure ALL statements granting certain actions have MFA
28
+ - Detect overly permissive statements (all_of)
29
+ - Ensure privilege escalation combinations are protected
17
30
 
18
31
  Configuration in iam-validator.yaml:
19
32
 
20
33
  checks:
21
34
  action_condition_enforcement:
22
35
  enabled: true
23
- severity: error
36
+ severity: high
24
37
  description: "Enforce specific conditions for specific actions"
25
38
 
26
- # STATEMENT-LEVEL: Check individual statements (default)
27
39
  action_condition_requirements:
28
40
  # BASIC: Simple action with required condition
29
41
  - actions:
@@ -87,56 +99,38 @@ Configuration in iam-validator.yaml:
87
99
  expected_value: false
88
100
  description: "Ensure insecure transport is never allowed"
89
101
 
90
- # none_of for actions: Flag if forbidden actions are present
91
- - actions:
92
- none_of:
93
- - "iam:*"
94
- - "s3:DeleteBucket"
95
- description: "These dangerous actions should never be used"
96
-
97
- # POLICY-LEVEL: Scan entire policy and enforce conditions across all matching statements
98
- policy_level_requirements:
99
- # Example: If ANY statement grants privilege escalation actions,
100
- # then ALL such statements must have MFA
102
+ # any_of for actions: If ANY statement grants privilege escalation actions, require MFA
101
103
  - actions:
102
104
  any_of:
103
105
  - "iam:CreateUser"
104
106
  - "iam:AttachUserPolicy"
105
107
  - "iam:PutUserPolicy"
106
- scope: "policy"
107
108
  required_conditions:
108
109
  - condition_key: "aws:MultiFactorAuthPresent"
109
110
  expected_value: true
110
- description: "Privilege escalation actions require MFA across entire policy"
111
+ description: "Privilege escalation actions require MFA"
111
112
  severity: "critical"
112
113
 
113
- # Example: All admin actions across the policy must have MFA
114
- - actions:
115
- any_of:
116
- - "iam:*"
117
- - "s3:*"
118
- scope: "policy"
119
- required_conditions:
120
- all_of:
121
- - condition_key: "aws:MultiFactorAuthPresent"
122
- expected_value: true
123
- - condition_key: "aws:SourceIp"
124
- apply_to: "all_matching_statements"
125
-
126
- # Example: Ensure no statement in the policy allows dangerous combinations
114
+ # all_of for actions: Flag statements that contain BOTH dangerous actions (overly permissive)
127
115
  - actions:
128
116
  all_of:
129
117
  - "iam:CreateAccessKey"
130
118
  - "iam:UpdateAccessKey"
131
- scope: "policy"
132
119
  severity: "critical"
133
- description: "Dangerous combination of actions detected in policy"
120
+ description: "Statement grants both CreateAccessKey and UpdateAccessKey - too permissive"
121
+
122
+ # none_of for actions: Flag if forbidden actions are present
123
+ - actions:
124
+ none_of:
125
+ - "iam:DeleteUser"
126
+ - "s3:DeleteBucket"
127
+ description: "These dangerous actions should never be used"
134
128
  """
135
129
 
136
130
  import re
137
- from typing import TYPE_CHECKING, Any
131
+ from typing import TYPE_CHECKING, Any, ClassVar
138
132
 
139
- from iam_validator.core.aws_fetcher import AWSServiceFetcher
133
+ from iam_validator.core.aws_service import AWSServiceFetcher
140
134
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
141
135
  from iam_validator.core.models import Statement, ValidationIssue
142
136
 
@@ -147,204 +141,526 @@ if TYPE_CHECKING:
147
141
  class ActionConditionEnforcementCheck(PolicyCheck):
148
142
  """Enforces specific condition requirements for specific actions with all_of/any_of support."""
149
143
 
150
- @property
151
- def check_id(self) -> str:
152
- return "action_condition_enforcement"
144
+ check_id: ClassVar[str] = "action_condition_enforcement"
145
+ description: ClassVar[str] = (
146
+ "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
147
+ )
148
+ default_severity: ClassVar[str] = "error"
153
149
 
154
- @property
155
- def description(self) -> str:
156
- return "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
150
+ async def execute_policy(
151
+ self,
152
+ policy: "IAMPolicy",
153
+ policy_file: str,
154
+ fetcher: AWSServiceFetcher,
155
+ config: CheckConfig,
156
+ **kwargs,
157
+ ) -> list[ValidationIssue]:
158
+ """
159
+ Execute policy-wide condition enforcement check.
160
+
161
+ This method scans the entire policy once and enforces conditions based on action matching:
162
+ - Simple list: Checks each statement for matching actions
163
+ - all_of: Finds statements that contain ALL specified actions (overly permissive detection)
164
+ - any_of: Finds statements that contain ANY of the specified actions
165
+ - none_of: Flags statements that contain forbidden actions
166
+
167
+ Example use cases:
168
+ - any_of: "If ANY statement grants iam:CreateUser, iam:AttachUserPolicy,
169
+ or iam:PutUserPolicy, then ALL such statements must have MFA condition."
170
+ - all_of: "Flag statements that grant BOTH iam:CreateAccessKey AND
171
+ iam:UpdateAccessKey (overly permissive)"
172
+
173
+ Args:
174
+ policy: The complete IAM policy to check
175
+ policy_file: Path to the policy file (for context/reporting)
176
+ fetcher: AWS service fetcher for validation against AWS APIs
177
+ config: CheckConfig: Configuration for this check instance
178
+ **kwargs: Additional context (policy_type, etc.)
157
179
 
158
- @property
159
- def default_severity(self) -> str:
160
- return "error"
180
+ Returns:
181
+ List of ValidationIssue objects found by this check
182
+ """
183
+ del policy_file, kwargs # Not used in current implementation
184
+ issues = []
161
185
 
162
- async def execute(
186
+ # Get action condition requirements from config
187
+ # Support both old (policy_level_requirements) and new (action_condition_requirements) keys
188
+ requirements = config.config.get(
189
+ "action_condition_requirements",
190
+ config.config.get("policy_level_requirements", []),
191
+ )
192
+
193
+ if not requirements:
194
+ return issues
195
+
196
+ # Process each requirement
197
+ for requirement in requirements:
198
+ # Check if actions use all_of/any_of/none_of (policy-wide) or simple list (per-statement)
199
+ actions_config = requirement.get("actions", [])
200
+ uses_logical_operators = isinstance(actions_config, dict) and any(
201
+ key in actions_config for key in ("all_of", "any_of", "none_of")
202
+ )
203
+
204
+ if uses_logical_operators:
205
+ # Policy-wide detection (all_of/any_of/none_of)
206
+ policy_issues = await self._check_policy_wide(policy, requirement, fetcher, config)
207
+ issues.extend(policy_issues)
208
+ else:
209
+ # Per-statement check (simple list)
210
+ statement_issues = await self._check_per_statement(
211
+ policy, requirement, fetcher, config
212
+ )
213
+ issues.extend(statement_issues)
214
+
215
+ return issues
216
+
217
+ async def _check_policy_wide(
163
218
  self,
164
- statement: Statement,
165
- statement_idx: int,
219
+ policy: "IAMPolicy",
220
+ requirement: dict[str, Any],
221
+ fetcher: AWSServiceFetcher,
222
+ config: CheckConfig,
223
+ ) -> list[ValidationIssue]:
224
+ """
225
+ Check actions across the entire policy using all_of/any_of/none_of logic.
226
+
227
+ This enables policy-wide detection patterns:
228
+ - all_of: ALL required actions must exist somewhere in the policy
229
+ - any_of: At least ONE required action must exist somewhere in the policy
230
+ - none_of: NONE of the forbidden actions should exist in the policy
231
+ """
232
+ issues = []
233
+ actions_config = requirement.get("actions", {})
234
+ all_of = actions_config.get("all_of", [])
235
+ any_of = actions_config.get("any_of", [])
236
+ none_of = actions_config.get("none_of", [])
237
+
238
+ # Collect all actions across the entire policy
239
+ policy_wide_actions: set[str] = set()
240
+ statements_by_action: dict[str, list[tuple[int, Statement]]] = {}
241
+
242
+ for idx, statement in enumerate(policy.statement or []):
243
+ if statement.effect != "Allow":
244
+ continue
245
+
246
+ statement_actions = statement.get_actions()
247
+ policy_wide_actions.update(statement_actions)
248
+
249
+ # Track which statements grant which actions
250
+ for action in statement_actions:
251
+ if action not in statements_by_action:
252
+ statements_by_action[action] = []
253
+ statements_by_action[action].append((idx, statement))
254
+
255
+ # Check all_of: ALL required actions must exist in policy
256
+ if all_of:
257
+ all_of_result = await self._check_all_of_policy_wide(
258
+ all_of,
259
+ policy_wide_actions,
260
+ statements_by_action,
261
+ requirement,
262
+ fetcher,
263
+ config,
264
+ )
265
+ issues.extend(all_of_result)
266
+
267
+ # Check any_of: At least ONE required action must exist in policy
268
+ if any_of:
269
+ any_of_result = await self._check_any_of_policy_wide(
270
+ any_of,
271
+ policy_wide_actions,
272
+ statements_by_action,
273
+ requirement,
274
+ fetcher,
275
+ config,
276
+ )
277
+ issues.extend(any_of_result)
278
+
279
+ # Check none_of: NONE of the forbidden actions should exist in policy
280
+ if none_of:
281
+ none_of_result = await self._check_none_of_policy_wide(
282
+ none_of,
283
+ policy_wide_actions,
284
+ statements_by_action,
285
+ requirement,
286
+ config,
287
+ fetcher,
288
+ )
289
+ issues.extend(none_of_result)
290
+
291
+ return issues
292
+
293
+ async def _check_all_of_policy_wide(
294
+ self,
295
+ all_of_actions: list[str],
296
+ policy_wide_actions: set[str],
297
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
298
+ requirement: dict[str, Any],
166
299
  fetcher: AWSServiceFetcher,
167
300
  config: CheckConfig,
168
301
  ) -> list[ValidationIssue]:
169
- """Execute statement-level condition enforcement check."""
302
+ """
303
+ Check if ALL required actions exist anywhere in the policy.
304
+
305
+ For all_of, we report ONLY statements that contain ALL the required actions,
306
+ not statements that contain just some of them. This is useful for detecting
307
+ overly permissive individual statements.
308
+ """
170
309
  issues = []
171
310
 
172
- # Only check Allow statements
173
- if statement.effect != "Allow":
311
+ # First, check if ALL required actions exist somewhere in the policy
312
+ found_actions_mapping: dict[str, str] = {} # req_action -> matched_policy_action
313
+ missing_actions: list[str] = []
314
+
315
+ for req_action in all_of_actions:
316
+ action_found = False
317
+ for policy_action in policy_wide_actions:
318
+ if await self._action_matches(
319
+ policy_action, req_action, requirement.get("action_patterns", []), fetcher
320
+ ):
321
+ action_found = True
322
+ found_actions_mapping[req_action] = policy_action
323
+ break
324
+
325
+ if not action_found:
326
+ missing_actions.append(req_action)
327
+
328
+ # If not all actions exist in the policy, no issue
329
+ if missing_actions:
174
330
  return issues
175
331
 
176
- # Get action condition requirements from config
177
- action_condition_requirements = config.config.get("action_condition_requirements", [])
178
- if not action_condition_requirements:
332
+ # ALL required actions exist in the policy
333
+ # Now find statements that have ALL of them (not just some)
334
+ statements_with_all_actions: list[tuple[int, Statement, list[str]]] = []
335
+
336
+ # Check each statement to see if it contains ALL required actions
337
+ for statement in statements_by_action.get(list(found_actions_mapping.values())[0], []):
338
+ stmt_idx, stmt = statement
339
+ stmt_actions = stmt.get_actions()
340
+
341
+ # Check if this statement has ALL required actions
342
+ has_all_actions = True
343
+ matched_actions = []
344
+
345
+ for req_action in all_of_actions:
346
+ req_action_found = False
347
+ for stmt_action in stmt_actions:
348
+ if await self._action_matches(
349
+ stmt_action, req_action, requirement.get("action_patterns", []), fetcher
350
+ ):
351
+ req_action_found = True
352
+ if stmt_action not in matched_actions:
353
+ matched_actions.append(stmt_action)
354
+ break
355
+
356
+ if not req_action_found:
357
+ has_all_actions = False
358
+ break
359
+
360
+ if has_all_actions:
361
+ statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
362
+
363
+ # Also check other statements not in the first action's list
364
+ checked_indices = {s[0] for s in statements_with_all_actions}
365
+ for policy_action, stmt_list in statements_by_action.items():
366
+ for stmt_idx, stmt in stmt_list:
367
+ if stmt_idx in checked_indices:
368
+ continue
369
+
370
+ stmt_actions = stmt.get_actions()
371
+
372
+ # Check if this statement has ALL required actions
373
+ has_all_actions = True
374
+ matched_actions = []
375
+
376
+ for req_action in all_of_actions:
377
+ req_action_found = False
378
+ for stmt_action in stmt_actions:
379
+ if await self._action_matches(
380
+ stmt_action, req_action, requirement.get("action_patterns", []), fetcher
381
+ ):
382
+ req_action_found = True
383
+ if stmt_action not in matched_actions:
384
+ matched_actions.append(stmt_action)
385
+ break
386
+
387
+ if not req_action_found:
388
+ has_all_actions = False
389
+ break
390
+
391
+ if has_all_actions:
392
+ statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
393
+ checked_indices.add(stmt_idx)
394
+
395
+ # If no statements have ALL actions, no issue to report
396
+ if not statements_with_all_actions:
179
397
  return issues
180
398
 
181
- statement_actions = statement.get_actions()
399
+ # Report statements that have ALL the dangerous actions
400
+ return self._generate_policy_wide_issues(
401
+ statements_with_all_actions,
402
+ list(found_actions_mapping.values()),
403
+ requirement,
404
+ config,
405
+ "all_of",
406
+ )
182
407
 
183
- # Check each requirement rule
184
- for requirement in action_condition_requirements:
185
- # Check if this requirement applies to the statement's actions
186
- actions_match, matching_actions = await self._check_action_match(
187
- statement_actions, requirement, fetcher
188
- )
408
+ async def _check_any_of_policy_wide(
409
+ self,
410
+ any_of_actions: list[str],
411
+ policy_wide_actions: set[str],
412
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
413
+ requirement: dict[str, Any],
414
+ fetcher: AWSServiceFetcher,
415
+ config: CheckConfig,
416
+ ) -> list[ValidationIssue]:
417
+ """Check if at least ONE required action exists anywhere in the policy."""
418
+ issues = []
419
+ found_actions: list[str] = []
420
+ statements_with_required_actions: list[tuple[int, Statement, list[str]]] = []
421
+
422
+ for req_action in any_of_actions:
423
+ for policy_action in policy_wide_actions:
424
+ if await self._action_matches(
425
+ policy_action, req_action, requirement.get("action_patterns", []), fetcher
426
+ ):
427
+ found_actions.append(policy_action)
428
+
429
+ # Track statements that have this action
430
+ if policy_action in statements_by_action:
431
+ for stmt_idx, stmt in statements_by_action[policy_action]:
432
+ existing = next(
433
+ (s for s in statements_with_required_actions if s[0] == stmt_idx),
434
+ None,
435
+ )
436
+ if existing:
437
+ if policy_action not in existing[2]:
438
+ existing[2].append(policy_action)
439
+ else:
440
+ statements_with_required_actions.append(
441
+ (stmt_idx, stmt, [policy_action])
442
+ )
443
+
444
+ # If no actions found, no issue
445
+ if not found_actions:
446
+ return issues
189
447
 
190
- if not actions_match:
191
- continue
448
+ # At least one action found - validate conditions
449
+ return self._generate_policy_wide_issues(
450
+ statements_with_required_actions,
451
+ found_actions,
452
+ requirement,
453
+ config,
454
+ "any_of",
455
+ )
192
456
 
193
- # Check if this is a none_of action rule (forbidden actions)
194
- actions_config = requirement.get("actions", [])
195
- if isinstance(actions_config, dict) and "none_of" in actions_config:
196
- # This is a forbidden action rule - flag it
197
- description = requirement.get("description", "These actions should not be used")
198
- # Use per-requirement severity if specified, else use global
199
- severity = requirement.get("severity", self.get_severity(config))
200
- issues.append(
201
- ValidationIssue(
202
- severity=severity,
203
- statement_sid=statement.sid,
204
- statement_index=statement_idx,
205
- issue_type="forbidden_action_present",
206
- message=f"FORBIDDEN: Actions {matching_actions} should not be used. {description}",
207
- action=", ".join(matching_actions),
208
- suggestion=f"Remove these forbidden actions from the statement: {', '.join(matching_actions)}. {description}",
209
- line_number=statement.line_number,
210
- )
457
+ async def _check_none_of_policy_wide(
458
+ self,
459
+ none_of_actions: list[str],
460
+ policy_wide_actions: set[str],
461
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
462
+ requirement: dict[str, Any],
463
+ config: CheckConfig,
464
+ fetcher: AWSServiceFetcher,
465
+ ) -> list[ValidationIssue]:
466
+ """Check if any forbidden actions exist in the policy."""
467
+ issues = []
468
+ forbidden_found: list[str] = []
469
+ statements_with_forbidden: list[tuple[int, Statement, list[str]]] = []
470
+
471
+ for forbidden_action in none_of_actions:
472
+ for policy_action in policy_wide_actions:
473
+ if await self._action_matches(
474
+ policy_action, forbidden_action, requirement.get("action_patterns", []), fetcher
475
+ ):
476
+ forbidden_found.append(policy_action)
477
+
478
+ # Track statements with forbidden actions
479
+ if policy_action in statements_by_action:
480
+ for stmt_idx, stmt in statements_by_action[policy_action]:
481
+ existing = next(
482
+ (s for s in statements_with_forbidden if s[0] == stmt_idx), None
483
+ )
484
+ if existing:
485
+ if policy_action not in existing[2]:
486
+ existing[2].append(policy_action)
487
+ else:
488
+ statements_with_forbidden.append((stmt_idx, stmt, [policy_action]))
489
+
490
+ # If forbidden actions found, create issues
491
+ if not forbidden_found:
492
+ return issues
493
+
494
+ description = requirement.get("description", "These actions should not be used")
495
+ severity = requirement.get("severity", self.get_severity(config))
496
+
497
+ for stmt_idx, stmt, actions in statements_with_forbidden:
498
+ actions_formatted = ", ".join(f"`{a}`" for a in actions)
499
+ statement_refs = [
500
+ f"Statement #{idx + 1}{' (SID: ' + s.sid + ')' if s.sid else ''}"
501
+ for idx, s, _ in statements_with_forbidden
502
+ ]
503
+
504
+ issues.append(
505
+ ValidationIssue(
506
+ severity=severity,
507
+ statement_sid=stmt.sid,
508
+ statement_index=stmt_idx,
509
+ issue_type="forbidden_action",
510
+ message=f"Forbidden actions {actions_formatted} found. {description}",
511
+ action=", ".join(actions),
512
+ suggestion=f"Remove these forbidden actions. Found in: {', '.join(statement_refs)}. {description}",
513
+ line_number=stmt.line_number,
211
514
  )
212
- continue
515
+ )
213
516
 
214
- # Actions match - now validate required conditions
215
- required_conditions_config = requirement.get("required_conditions", [])
216
- if not required_conditions_config:
217
- continue
517
+ return issues
218
518
 
219
- # Check if conditions are in all_of/any_of/none_of format or simple list
519
+ def _generate_policy_wide_issues(
520
+ self,
521
+ statements_with_actions: list[tuple[int, Statement, list[str]]],
522
+ found_actions: list[str],
523
+ requirement: dict[str, Any],
524
+ config: CheckConfig,
525
+ operator_type: str,
526
+ ) -> list[ValidationIssue]:
527
+ """Generate validation issues for policy-wide checks."""
528
+ issues = []
529
+ required_conditions_config = requirement.get("required_conditions", [])
530
+ description = requirement.get("description", "")
531
+ severity = requirement.get("severity", self.get_severity(config))
532
+
533
+ if not required_conditions_config:
534
+ # No conditions specified, just report that actions were found
535
+ all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(set(found_actions)))
536
+ statement_refs = [
537
+ f"Statement #{idx + 1}{' (SID: ' + stmt.sid + ')' if stmt.sid else ''}"
538
+ for idx, stmt, _ in statements_with_actions
539
+ ]
540
+
541
+ first_idx, first_stmt, _ = statements_with_actions[0]
542
+ issues.append(
543
+ ValidationIssue(
544
+ severity=severity,
545
+ statement_sid=first_stmt.sid,
546
+ statement_index=first_idx,
547
+ issue_type="action_detected",
548
+ message=f"Actions {all_actions_formatted} found across {len(statements_with_actions)} statement(s) ({operator_type}). {description}",
549
+ action=", ".join(sorted(set(found_actions))),
550
+ suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
551
+ line_number=first_stmt.line_number,
552
+ )
553
+ )
554
+ return issues
555
+
556
+ # Validate conditions for each statement
557
+ for idx, statement, matching_actions in statements_with_actions:
220
558
  condition_issues = self._validate_conditions(
221
559
  statement,
222
- statement_idx,
560
+ idx,
223
561
  required_conditions_config,
224
562
  matching_actions,
225
563
  config,
226
- requirement, # Pass the full requirement for severity override
564
+ requirement,
227
565
  )
228
566
 
567
+ # Add context
568
+ for issue in condition_issues:
569
+ issue.suggestion = (
570
+ f"{issue.suggestion}\n\n"
571
+ f"Note: Found {len(statements_with_actions)} statement(s) with these actions in the policy ({operator_type})."
572
+ )
573
+
229
574
  issues.extend(condition_issues)
230
575
 
231
576
  return issues
232
577
 
233
- async def execute_policy(
578
+ async def _check_per_statement(
234
579
  self,
235
580
  policy: "IAMPolicy",
236
- policy_file: str,
581
+ requirement: dict[str, Any],
237
582
  fetcher: AWSServiceFetcher,
238
583
  config: CheckConfig,
239
- **kwargs,
240
584
  ) -> list[ValidationIssue]:
241
585
  """
242
- Execute policy-level condition enforcement check.
243
-
244
- This method scans the entire policy and enforces that ALL statements granting
245
- certain actions must have specific conditions. This is useful for ensuring
246
- consistent security controls across the entire policy.
247
-
248
- Example use case:
249
- - "If ANY statement in the policy grants iam:CreateUser, iam:AttachUserPolicy,
250
- or iam:PutUserPolicy, then ALL such statements must have MFA condition."
251
-
252
- Args:
253
- policy: The complete IAM policy to check
254
- policy_file: Path to the policy file (for context/reporting)
255
- fetcher: AWS service fetcher for validation against AWS APIs
256
- config: Configuration for this check instance
257
- **kwargs: Additional context (policy_type, etc.)
586
+ Check each statement individually for matching actions (simple list format).
258
587
 
259
- Returns:
260
- List of ValidationIssue objects found by this check
588
+ Used when actions are specified as a simple list (not using all_of/any_of/none_of).
261
589
  """
262
- del policy_file, kwargs # Not used in current implementation
263
590
  issues = []
591
+ matching_statements: list[tuple[int, Statement, list[str]]] = []
264
592
 
265
- # Get policy-level requirements from config
266
- policy_level_requirements = config.config.get("policy_level_requirements", [])
267
- if not policy_level_requirements:
268
- return issues
269
-
270
- # Process each policy-level requirement
271
- for requirement in policy_level_requirements:
272
- # Collect all statements that match the action criteria
273
- matching_statements: list[tuple[int, Statement, list[str]]] = []
274
-
275
- for idx, statement in enumerate(policy.statement):
276
- # Only check Allow statements
277
- if statement.effect != "Allow":
278
- continue
593
+ for idx, statement in enumerate(policy.statement or []):
594
+ # Only check Allow statements
595
+ if statement.effect != "Allow":
596
+ continue
279
597
 
280
- statement_actions = statement.get_actions()
598
+ statement_actions = statement.get_actions()
281
599
 
282
- # Check if this statement matches the action requirement
283
- actions_match, matching_actions = await self._check_action_match(
284
- statement_actions, requirement, fetcher
285
- )
600
+ # Check if this statement matches the action requirement
601
+ actions_match, matching_actions = await self._check_action_match(
602
+ statement_actions, requirement, fetcher
603
+ )
286
604
 
287
- if actions_match and matching_actions:
288
- matching_statements.append((idx, statement, matching_actions))
605
+ if actions_match and matching_actions:
606
+ matching_statements.append((idx, statement, matching_actions))
289
607
 
290
- # If no statements match, skip this requirement
291
- if not matching_statements:
292
- continue
608
+ # If no statements match, skip this requirement
609
+ if not matching_statements:
610
+ return issues
293
611
 
294
- # Now validate that ALL matching statements have the required conditions
295
- required_conditions_config = requirement.get("required_conditions", [])
296
- if not required_conditions_config:
297
- # No conditions specified, just report that actions were found
298
- description = requirement.get("description", "")
299
- severity = requirement.get("severity", self.get_severity(config))
300
-
301
- # Create a summary issue for all matching statements
302
- all_actions = set()
303
- statement_refs = []
304
- for idx, stmt, actions in matching_statements:
305
- all_actions.update(actions)
306
- sid_info = f" (SID: {stmt.sid})" if stmt.sid else ""
307
- statement_refs.append(f"Statement #{idx + 1}{sid_info}")
308
-
309
- # Use the first matching statement's index for the issue
310
- first_idx, first_stmt, _ = matching_statements[0]
311
-
312
- issues.append(
313
- ValidationIssue(
314
- severity=severity,
315
- statement_sid=first_stmt.sid,
316
- statement_index=first_idx,
317
- issue_type="policy_level_action_detected",
318
- message=f"POLICY-LEVEL: Actions {sorted(all_actions)} found in {len(matching_statements)} statement(s). {description}",
319
- action=", ".join(sorted(all_actions)),
320
- suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
321
- line_number=first_stmt.line_number,
322
- )
612
+ # Now validate that ALL matching statements have the required conditions
613
+ required_conditions_config = requirement.get("required_conditions", [])
614
+ if not required_conditions_config:
615
+ # No conditions specified, just report that actions were found
616
+ description = requirement.get("description", "")
617
+ severity = requirement.get("severity", self.get_severity(config))
618
+
619
+ # Create a summary issue for all matching statements
620
+ all_actions = set()
621
+ statement_refs = []
622
+ for idx, stmt, actions in matching_statements:
623
+ all_actions.update(actions)
624
+ sid_info = f" (SID: {stmt.sid})" if stmt.sid else ""
625
+ statement_refs.append(f"Statement #{idx + 1}{sid_info}")
626
+
627
+ # Use the first matching statement's index for the issue
628
+ first_idx, first_stmt, _ = matching_statements[0]
629
+ all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(all_actions))
630
+
631
+ issues.append(
632
+ ValidationIssue(
633
+ severity=severity,
634
+ statement_sid=first_stmt.sid,
635
+ statement_index=first_idx,
636
+ issue_type="action_detected",
637
+ message=f"Actions {all_actions_formatted} found in {len(matching_statements)} statement(s). {description}",
638
+ action=", ".join(sorted(all_actions)),
639
+ suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
640
+ line_number=first_stmt.line_number,
323
641
  )
324
- continue
642
+ )
643
+ return issues
325
644
 
326
- # Validate conditions for each matching statement
327
- for idx, statement, matching_actions in matching_statements:
328
- condition_issues = self._validate_conditions(
329
- statement,
330
- idx,
331
- required_conditions_config,
332
- matching_actions,
333
- config,
334
- requirement,
335
- )
645
+ # Validate conditions for each matching statement
646
+ for idx, statement, matching_actions in matching_statements:
647
+ condition_issues = self._validate_conditions(
648
+ statement,
649
+ idx,
650
+ required_conditions_config,
651
+ matching_actions,
652
+ config,
653
+ requirement,
654
+ )
336
655
 
337
- # Add policy-level context to each issue
338
- for issue in condition_issues:
339
- # Modify the message to indicate this is part of policy-level enforcement
340
- issue.message = f"POLICY-LEVEL: {issue.message}"
341
- issue.suggestion = (
342
- f"{issue.suggestion}\n\n"
343
- f"Note: This is enforced at the policy level. "
344
- f"Found {len(matching_statements)} statement(s) with these actions in the policy."
345
- )
656
+ # Add context to each issue
657
+ for issue in condition_issues:
658
+ issue.suggestion = (
659
+ f"{issue.suggestion}\n\n"
660
+ f"Note: Found {len(matching_statements)} statement(s) with these actions in the policy."
661
+ )
346
662
 
347
- issues.extend(condition_issues)
663
+ issues.extend(condition_issues)
348
664
 
349
665
  return issues
350
666
 
@@ -530,7 +846,7 @@ class ActionConditionEnforcementCheck(PolicyCheck):
530
846
  available_actions = list(service_detail.actions.keys())
531
847
 
532
848
  # Find which actual AWS actions the wildcard would grant
533
- _, granted_actions = fetcher._match_wildcard_action(
849
+ _, granted_actions = fetcher.match_wildcard_action(
534
850
  statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
535
851
  available_actions,
536
852
  )
@@ -545,7 +861,7 @@ class ActionConditionEnforcementCheck(PolicyCheck):
545
861
  except re.error:
546
862
  continue
547
863
 
548
- except (ValueError, Exception):
864
+ except (ValueError, Exception): # pylint: disable=broad-exception-caught
549
865
  # If we can't fetch the service or parse the action, fall back to prefix matching
550
866
  stmt_prefix = statement_action.rstrip("*")
551
867
  for pattern in patterns:
@@ -627,7 +943,20 @@ class ActionConditionEnforcementCheck(PolicyCheck):
627
943
 
628
944
  if not any_present:
629
945
  # Create a combined error for any_of
630
- condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
946
+ # Handle both simple conditions and nested all_of
947
+ condition_keys = []
948
+ for cond in any_of:
949
+ if "all_of" in cond:
950
+ # Nested all_of - collect all condition keys
951
+ nested_keys = [
952
+ c.get("condition_key", "unknown") for c in cond["all_of"]
953
+ ]
954
+ condition_keys.append(f"({' + '.join(f'`{k}`' for k in nested_keys)})")
955
+ else:
956
+ # Simple condition
957
+ condition_keys.append(f"`{cond.get('condition_key', 'unknown')}`")
958
+ condition_keys_formatted = " OR ".join(condition_keys)
959
+ matching_actions_formatted = ", ".join(f"`{a}`" for a in matching_actions)
631
960
  issues.append(
632
961
  ValidationIssue(
633
962
  severity=self.get_severity(config),
@@ -635,8 +964,8 @@ class ActionConditionEnforcementCheck(PolicyCheck):
635
964
  statement_index=statement_idx,
636
965
  issue_type="missing_required_condition_any_of",
637
966
  message=(
638
- f"Actions {matching_actions} require at least ONE of these conditions: "
639
- f"{', '.join(condition_keys)}"
967
+ f"Actions `{matching_actions_formatted}` require at least ONE of these conditions: "
968
+ f"{condition_keys_formatted}"
640
969
  ),
641
970
  action=", ".join(matching_actions),
642
971
  suggestion=self._build_any_of_suggestion(any_of),
@@ -773,12 +1102,13 @@ class ActionConditionEnforcementCheck(PolicyCheck):
773
1102
  condition_key, description, example, expected_value, operator
774
1103
  )
775
1104
 
1105
+ matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
776
1106
  return ValidationIssue(
777
1107
  severity=severity,
778
1108
  statement_sid=statement.sid,
779
1109
  statement_index=statement_idx,
780
1110
  issue_type="missing_required_condition",
781
- message=f"{message_prefix} Action(s) {matching_actions} require condition '{condition_key}'",
1111
+ message=f"{message_prefix} Action(s) `{matching_actions_str}` require condition `{condition_key}`",
782
1112
  action=", ".join(matching_actions),
783
1113
  condition_key=condition_key,
784
1114
  suggestion=suggestion_text,
@@ -799,7 +1129,7 @@ class ActionConditionEnforcementCheck(PolicyCheck):
799
1129
  Returns:
800
1130
  Tuple of (suggestion_text, example_code)
801
1131
  """
802
- suggestion = description if description else f"Add condition: {condition_key}"
1132
+ suggestion = description if description else f"Add condition: `{condition_key}`"
803
1133
 
804
1134
  # Build example based on condition key type
805
1135
  if example:
@@ -843,15 +1173,38 @@ class ActionConditionEnforcementCheck(PolicyCheck):
843
1173
  suggestions.append("Add at least ONE of these conditions:")
844
1174
 
845
1175
  for i, cond in enumerate(any_of_conditions, 1):
846
- condition_key = cond.get("condition_key", "unknown")
847
- description = cond.get("description", "")
848
- expected_value = cond.get("expected_value")
1176
+ # Handle nested all_of blocks
1177
+ if "all_of" in cond:
1178
+ # Nested all_of - show all required conditions together
1179
+ all_of_list = cond["all_of"]
1180
+ condition_keys = [c.get("condition_key", "unknown") for c in all_of_list]
1181
+ condition_keys_formatted = " + ".join(f"`{k}`" for k in condition_keys)
1182
+
1183
+ option = f"\n- **Option {i}**: {condition_keys_formatted} (both required)"
1184
+
1185
+ # Use description from first condition or combine them
1186
+ descriptions = [
1187
+ c.get("description", "") for c in all_of_list if c.get("description")
1188
+ ]
1189
+ if descriptions:
1190
+ option += f" - {descriptions[0]}"
1191
+
1192
+ # Show example from first condition that has one
1193
+ for c in all_of_list:
1194
+ if c.get("example"):
1195
+ # Example will be shown separately, just note it's available
1196
+ break
1197
+ else:
1198
+ # Simple condition (original behavior)
1199
+ condition_key = cond.get("condition_key", "unknown")
1200
+ description = cond.get("description", "")
1201
+ expected_value = cond.get("expected_value")
849
1202
 
850
- option = f"\nOption {i}: {condition_key}"
851
- if description:
852
- option += f" - {description}"
853
- if expected_value is not None:
854
- option += f" (value: {expected_value})"
1203
+ option = f"\n- **Option {i}**: `{condition_key}`"
1204
+ if description:
1205
+ option += f" - {description}"
1206
+ if expected_value is not None:
1207
+ option += f" (value: `{expected_value}`)"
855
1208
 
856
1209
  suggestions.append(option)
857
1210
 
@@ -870,13 +1223,12 @@ class ActionConditionEnforcementCheck(PolicyCheck):
870
1223
  description = condition_requirement.get("description", "")
871
1224
  expected_value = condition_requirement.get("expected_value")
872
1225
 
873
- message = (
874
- f"FORBIDDEN: Action(s) {matching_actions} must NOT have condition '{condition_key}'"
875
- )
1226
+ matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
1227
+ message = f"FORBIDDEN: Action(s) `{matching_actions_str}` must NOT have condition `{condition_key}`"
876
1228
  if expected_value is not None:
877
- message += f" with value '{expected_value}'"
1229
+ message += f" with value `{expected_value}`"
878
1230
 
879
- suggestion = f"Remove the '{condition_key}' condition from the statement"
1231
+ suggestion = f"Remove the `{condition_key}` condition from the statement"
880
1232
  if description:
881
1233
  suggestion += f". {description}"
882
1234