iam-policy-validator 1.14.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 (106) hide show
  1. iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
  2. iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
  3. iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +9 -0
  9. iam_validator/checks/__init__.py +45 -0
  10. iam_validator/checks/action_condition_enforcement.py +1442 -0
  11. iam_validator/checks/action_resource_matching.py +472 -0
  12. iam_validator/checks/action_validation.py +67 -0
  13. iam_validator/checks/condition_key_validation.py +88 -0
  14. iam_validator/checks/condition_type_mismatch.py +257 -0
  15. iam_validator/checks/full_wildcard.py +62 -0
  16. iam_validator/checks/mfa_condition_check.py +105 -0
  17. iam_validator/checks/policy_size.py +114 -0
  18. iam_validator/checks/policy_structure.py +556 -0
  19. iam_validator/checks/policy_type_validation.py +331 -0
  20. iam_validator/checks/principal_validation.py +708 -0
  21. iam_validator/checks/resource_validation.py +135 -0
  22. iam_validator/checks/sensitive_action.py +438 -0
  23. iam_validator/checks/service_wildcard.py +98 -0
  24. iam_validator/checks/set_operator_validation.py +153 -0
  25. iam_validator/checks/sid_uniqueness.py +146 -0
  26. iam_validator/checks/trust_policy_validation.py +509 -0
  27. iam_validator/checks/utils/__init__.py +17 -0
  28. iam_validator/checks/utils/action_parser.py +149 -0
  29. iam_validator/checks/utils/policy_level_checks.py +190 -0
  30. iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
  31. iam_validator/checks/utils/wildcard_expansion.py +86 -0
  32. iam_validator/checks/wildcard_action.py +58 -0
  33. iam_validator/checks/wildcard_resource.py +374 -0
  34. iam_validator/commands/__init__.py +31 -0
  35. iam_validator/commands/analyze.py +549 -0
  36. iam_validator/commands/base.py +48 -0
  37. iam_validator/commands/cache.py +393 -0
  38. iam_validator/commands/completion.py +471 -0
  39. iam_validator/commands/download_services.py +255 -0
  40. iam_validator/commands/post_to_pr.py +86 -0
  41. iam_validator/commands/query.py +485 -0
  42. iam_validator/commands/validate.py +830 -0
  43. iam_validator/core/__init__.py +13 -0
  44. iam_validator/core/access_analyzer.py +671 -0
  45. iam_validator/core/access_analyzer_report.py +640 -0
  46. iam_validator/core/aws_fetcher.py +29 -0
  47. iam_validator/core/aws_service/__init__.py +21 -0
  48. iam_validator/core/aws_service/cache.py +108 -0
  49. iam_validator/core/aws_service/client.py +205 -0
  50. iam_validator/core/aws_service/fetcher.py +641 -0
  51. iam_validator/core/aws_service/parsers.py +149 -0
  52. iam_validator/core/aws_service/patterns.py +51 -0
  53. iam_validator/core/aws_service/storage.py +291 -0
  54. iam_validator/core/aws_service/validators.py +380 -0
  55. iam_validator/core/check_registry.py +679 -0
  56. iam_validator/core/cli.py +134 -0
  57. iam_validator/core/codeowners.py +245 -0
  58. iam_validator/core/condition_validators.py +626 -0
  59. iam_validator/core/config/__init__.py +81 -0
  60. iam_validator/core/config/aws_api.py +35 -0
  61. iam_validator/core/config/aws_global_conditions.py +160 -0
  62. iam_validator/core/config/category_suggestions.py +181 -0
  63. iam_validator/core/config/check_documentation.py +390 -0
  64. iam_validator/core/config/condition_requirements.py +258 -0
  65. iam_validator/core/config/config_loader.py +670 -0
  66. iam_validator/core/config/defaults.py +739 -0
  67. iam_validator/core/config/principal_requirements.py +421 -0
  68. iam_validator/core/config/sensitive_actions.py +672 -0
  69. iam_validator/core/config/service_principals.py +132 -0
  70. iam_validator/core/config/wildcards.py +127 -0
  71. iam_validator/core/constants.py +149 -0
  72. iam_validator/core/diff_parser.py +325 -0
  73. iam_validator/core/finding_fingerprint.py +131 -0
  74. iam_validator/core/formatters/__init__.py +27 -0
  75. iam_validator/core/formatters/base.py +147 -0
  76. iam_validator/core/formatters/console.py +68 -0
  77. iam_validator/core/formatters/csv.py +171 -0
  78. iam_validator/core/formatters/enhanced.py +481 -0
  79. iam_validator/core/formatters/html.py +672 -0
  80. iam_validator/core/formatters/json.py +33 -0
  81. iam_validator/core/formatters/markdown.py +64 -0
  82. iam_validator/core/formatters/sarif.py +251 -0
  83. iam_validator/core/ignore_patterns.py +297 -0
  84. iam_validator/core/ignore_processor.py +309 -0
  85. iam_validator/core/ignored_findings.py +400 -0
  86. iam_validator/core/label_manager.py +197 -0
  87. iam_validator/core/models.py +404 -0
  88. iam_validator/core/policy_checks.py +220 -0
  89. iam_validator/core/policy_loader.py +785 -0
  90. iam_validator/core/pr_commenter.py +780 -0
  91. iam_validator/core/report.py +942 -0
  92. iam_validator/integrations/__init__.py +28 -0
  93. iam_validator/integrations/github_integration.py +1821 -0
  94. iam_validator/integrations/ms_teams.py +442 -0
  95. iam_validator/sdk/__init__.py +220 -0
  96. iam_validator/sdk/arn_matching.py +382 -0
  97. iam_validator/sdk/context.py +222 -0
  98. iam_validator/sdk/exceptions.py +48 -0
  99. iam_validator/sdk/helpers.py +177 -0
  100. iam_validator/sdk/policy_utils.py +451 -0
  101. iam_validator/sdk/query_utils.py +454 -0
  102. iam_validator/sdk/shortcuts.py +283 -0
  103. iam_validator/utils/__init__.py +35 -0
  104. iam_validator/utils/cache.py +105 -0
  105. iam_validator/utils/regex.py +205 -0
  106. iam_validator/utils/terminal.py +22 -0
@@ -0,0 +1,1442 @@
1
+ """Action-Specific Condition Enforcement Check.
2
+
3
+ Ensures specific actions have required IAM conditions (MFA, IP, tags, etc.).
4
+
5
+ Action Matching Modes:
6
+ - Simple list: actions: ["iam:PassRole"]
7
+ - any_of: Require conditions if ANY action matches
8
+ - all_of: Require conditions if ALL actions present (overly permissive detection)
9
+ - none_of: Flag forbidden actions
10
+
11
+ Merge Strategies (merge_strategy setting):
12
+ - append (default): User + defaults both apply
13
+ - user_only: Disable ALL defaults, use only user requirements
14
+ - per_action_override: User replaces defaults for matching actions
15
+ - replace_all: User replaces all if provided
16
+ - defaults_only: Ignore user, use only defaults
17
+
18
+ For full documentation, see: docs/condition-requirements.md
19
+ """
20
+
21
+ import re
22
+ from typing import TYPE_CHECKING, Any, ClassVar
23
+
24
+ from iam_validator.core.aws_service import AWSServiceFetcher
25
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
26
+ from iam_validator.core.ignore_patterns import IgnorePatternMatcher
27
+ from iam_validator.core.models import Statement, ValidationIssue
28
+ from iam_validator.utils.regex import compile_and_cache
29
+
30
+ if TYPE_CHECKING:
31
+ from iam_validator.core.models import IAMPolicy
32
+
33
+
34
+ class ActionConditionEnforcementCheck(PolicyCheck):
35
+ """Enforces specific condition requirements for specific actions with all_of/any_of support."""
36
+
37
+ check_id: ClassVar[str] = "action_condition_enforcement"
38
+ description: ClassVar[str] = (
39
+ "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
40
+ )
41
+ default_severity: ClassVar[str] = "error"
42
+
43
+ async def execute_policy(
44
+ self,
45
+ policy: "IAMPolicy",
46
+ policy_file: str,
47
+ fetcher: AWSServiceFetcher,
48
+ config: CheckConfig,
49
+ **kwargs,
50
+ ) -> list[ValidationIssue]:
51
+ """
52
+ Execute policy-wide condition enforcement check.
53
+
54
+ This method scans the entire policy once and enforces conditions based on action matching:
55
+ - Simple list: Checks each statement for matching actions
56
+ - all_of: Finds statements that contain ALL specified actions (overly permissive detection)
57
+ - any_of: Finds statements that contain ANY of the specified actions
58
+ - none_of: Flags statements that contain forbidden actions
59
+
60
+ Example use cases:
61
+ - any_of: "If ANY statement grants iam:CreateUser, iam:AttachUserPolicy,
62
+ or iam:PutUserPolicy, then ALL such statements must have MFA condition."
63
+ - all_of: "Flag statements that grant BOTH iam:CreateAccessKey AND
64
+ iam:UpdateAccessKey (overly permissive)"
65
+
66
+ Args:
67
+ policy: The complete IAM policy to check
68
+ policy_file: Path to the policy file (for context/reporting)
69
+ fetcher: AWS service fetcher for validation against AWS APIs
70
+ config: CheckConfig: Configuration for this check instance
71
+ **kwargs: Additional context (policy_type, etc.)
72
+
73
+ Returns:
74
+ List of ValidationIssue objects found by this check
75
+ """
76
+ del kwargs # Not used in current implementation
77
+ issues = []
78
+
79
+ # Get action condition requirements using configurable merge strategy
80
+ requirements = self._get_merged_requirements(config, policy_file)
81
+
82
+ if not requirements:
83
+ return issues
84
+
85
+ # Process each requirement
86
+ for requirement in requirements:
87
+ # Check if actions use all_of/any_of/none_of (policy-wide) or simple list (per-statement)
88
+ actions_config = requirement.get("actions", [])
89
+ uses_logical_operators = isinstance(actions_config, dict) and any(
90
+ key in actions_config for key in ("all_of", "any_of", "none_of")
91
+ )
92
+
93
+ if uses_logical_operators:
94
+ # Policy-wide detection (all_of/any_of/none_of)
95
+ policy_issues = await self._check_policy_wide(policy, requirement, fetcher, config)
96
+ # Filter by requirement-level ignore_patterns
97
+ policy_issues = self._filter_requirement_issues(
98
+ policy_issues, requirement.get("ignore_patterns", []), policy_file
99
+ )
100
+ issues.extend(policy_issues)
101
+ else:
102
+ # Per-statement check (simple list)
103
+ statement_issues = await self._check_per_statement(
104
+ policy, requirement, fetcher, config
105
+ )
106
+ # Filter by requirement-level ignore_patterns
107
+ statement_issues = self._filter_requirement_issues(
108
+ statement_issues, requirement.get("ignore_patterns", []), policy_file
109
+ )
110
+ issues.extend(statement_issues)
111
+
112
+ return issues
113
+
114
+ def _get_merged_requirements(
115
+ self,
116
+ config: CheckConfig,
117
+ policy_file: str,
118
+ ) -> list[dict[str, Any]]:
119
+ """
120
+ Get merged requirements based on configured merge strategy.
121
+
122
+ Supports multiple merge strategies to control how user requirements
123
+ interact with default requirements:
124
+ - "per_action_override": User requirements replace defaults for matching actions (default)
125
+ - "append": User requirements added to defaults (both apply)
126
+ - "replace_all": User requirements completely replace ALL defaults
127
+ - "defaults_only": Ignore user requirements, use only defaults
128
+ - "user_only": Ignore defaults, use only user requirements
129
+
130
+ Args:
131
+ config: Check configuration containing requirements and merge strategy
132
+ policy_file: Path to the policy file being checked (for ignore_patterns)
133
+
134
+ Returns:
135
+ Merged list of requirements based on strategy
136
+ """
137
+ # Get default and user requirements
138
+ default_requirements = config.config.get(
139
+ "requirements",
140
+ config.config.get("policy_level_requirements", []),
141
+ )
142
+ user_requirements = config.config.get("action_condition_requirements")
143
+
144
+ # Get merge strategy (default: append - both defaults and user requirements apply)
145
+ merge_strategy = config.config.get("merge_strategy", "append")
146
+
147
+ # For user_only, replace_all, and per_action_override:
148
+ # Filter user requirements by ignore_patterns BEFORE merging
149
+ # For append and defaults_only: ignore_patterns on user requirements still apply
150
+ if user_requirements:
151
+ active_user_requirements = self._filter_requirements_by_filepath(
152
+ user_requirements, policy_file
153
+ )
154
+ else:
155
+ active_user_requirements = []
156
+
157
+ # Apply merge strategy
158
+ if merge_strategy == "user_only":
159
+ # Use ONLY user requirements - no defaults at all
160
+ # If a user requirement is filtered by ignore_patterns, it's simply not checked
161
+ return active_user_requirements
162
+
163
+ elif merge_strategy == "defaults_only":
164
+ # Use ONLY defaults - ignore all user requirements
165
+ return default_requirements
166
+
167
+ elif merge_strategy == "replace_all":
168
+ # User requirements completely replace ALL defaults (if user provided any)
169
+ # If no user requirements provided, fall back to defaults
170
+ if user_requirements: # Check original, not filtered
171
+ return active_user_requirements
172
+ return default_requirements
173
+
174
+ elif merge_strategy == "per_action_override":
175
+ # User requirements replace defaults for MATCHING actions only
176
+ # Non-matching defaults are kept
177
+ # Note: We use the ORIGINAL user_requirements to determine which actions
178
+ # are "user-defined" (even if filtered out by ignore_patterns)
179
+ return self._merge_per_action_override(
180
+ default_requirements, user_requirements or [], active_user_requirements
181
+ )
182
+
183
+ else: # "append" (default)
184
+ # Both defaults AND user requirements apply
185
+ # User requirements are added on top of defaults
186
+ return default_requirements + active_user_requirements
187
+
188
+ def _filter_requirements_by_filepath(
189
+ self,
190
+ requirements: list[dict[str, Any]],
191
+ policy_file: str,
192
+ ) -> list[dict[str, Any]]:
193
+ """
194
+ Filter out requirements that should be ignored for this file.
195
+
196
+ This handles ignore_patterns at the requirement level BEFORE merging,
197
+ allowing defaults to apply when user requirements are ignored.
198
+
199
+ Args:
200
+ requirements: List of requirements to filter
201
+ policy_file: Path to the policy file being checked
202
+
203
+ Returns:
204
+ Filtered list of requirements (excluding ignored ones)
205
+
206
+ Example:
207
+ User defines: iam:CreateRole with ignore_patterns: [".*test/.*"]
208
+ When checking test/policy.json:
209
+ - User requirement is filtered out
210
+ - Default iam:CreateRole requirement can apply instead
211
+ """
212
+ active_reqs = []
213
+
214
+ for req in requirements:
215
+ ignore_patterns = req.get("ignore_patterns", [])
216
+
217
+ if not ignore_patterns:
218
+ # No ignore patterns - include this requirement
219
+ active_reqs.append(req)
220
+ continue
221
+
222
+ # Check if any ignore pattern matches this file
223
+ should_ignore = self._should_ignore_filepath(policy_file, ignore_patterns)
224
+
225
+ if not should_ignore:
226
+ active_reqs.append(req)
227
+
228
+ return active_reqs
229
+
230
+ def _should_ignore_filepath(
231
+ self,
232
+ filepath: str,
233
+ ignore_patterns: list[dict[str, Any]],
234
+ ) -> bool:
235
+ """
236
+ Check if filepath matches any of the ignore patterns.
237
+
238
+ Only checks filepath-based patterns (filepath, filepath_regex).
239
+ This is used for filtering requirements before merging.
240
+
241
+ Args:
242
+ filepath: Path to the policy file
243
+ ignore_patterns: List of ignore pattern dictionaries
244
+
245
+ Returns:
246
+ True if filepath matches any pattern
247
+ """
248
+ for pattern in ignore_patterns:
249
+ # Only check filepath-based patterns
250
+ if "filepath" in pattern or "filepath_regex" in pattern:
251
+ regex_pattern = pattern.get("filepath") or pattern.get("filepath_regex")
252
+ if regex_pattern:
253
+ compiled = compile_and_cache(regex_pattern)
254
+ if compiled and compiled.search(filepath):
255
+ return True
256
+ return False
257
+
258
+ def _merge_per_action_override(
259
+ self,
260
+ default_requirements: list[dict[str, Any]],
261
+ all_user_requirements: list[dict[str, Any]],
262
+ active_user_requirements: list[dict[str, Any]],
263
+ ) -> list[dict[str, Any]]:
264
+ """
265
+ Merge user requirements with defaults on a per-action basis.
266
+
267
+ User requirements override defaults for matching actions.
268
+ Defaults are kept for actions not specified by user.
269
+
270
+ Key behavior with ignore_patterns:
271
+ - If user defines a requirement for action X with ignore_patterns
272
+ - And the current file matches the ignore_patterns
273
+ - Then: The user requirement is SKIPPED (not applied)
274
+ - AND: The default for action X is ALSO skipped (user "owns" this action)
275
+ - Result: No check for action X on this file
276
+
277
+ Args:
278
+ default_requirements: Default requirements from system config
279
+ all_user_requirements: ALL user requirements (before ignore_patterns filtering)
280
+ active_user_requirements: User requirements after ignore_patterns filtering
281
+
282
+ Returns:
283
+ Merged list of requirements
284
+ """
285
+ # Build a set of actions that user has customized (from ALL user requirements)
286
+ # This determines which defaults to exclude
287
+ user_actions = set()
288
+ for req in all_user_requirements:
289
+ actions = req.get("actions", [])
290
+ # Handle both single action and list of actions
291
+ if isinstance(actions, str):
292
+ user_actions.add(actions)
293
+ else:
294
+ user_actions.update(actions)
295
+
296
+ # Start with ACTIVE user requirements (filtered by ignore_patterns)
297
+ merged = list(active_user_requirements)
298
+
299
+ # Add defaults that don't conflict with user requirements
300
+ for default_req in default_requirements:
301
+ default_actions = default_req.get("actions", [])
302
+ # Handle both single action and list of actions
303
+ if isinstance(default_actions, str):
304
+ default_actions = [default_actions]
305
+
306
+ # Check if any of the default actions are customized by user
307
+ has_overlap = any(action in user_actions for action in default_actions)
308
+
309
+ if not has_overlap:
310
+ # No overlap - keep this default requirement
311
+ merged.append(default_req)
312
+
313
+ return merged
314
+
315
+ def _filter_requirement_issues(
316
+ self,
317
+ issues: list[ValidationIssue],
318
+ ignore_patterns: list[dict[str, Any]],
319
+ filepath: str,
320
+ ) -> list[ValidationIssue]:
321
+ """
322
+ Filter issues based on requirement-level ignore patterns.
323
+
324
+ This allows each requirement within action_condition_enforcement to have its own
325
+ ignore patterns, enabling fine-grained control over which findings to suppress.
326
+
327
+ Args:
328
+ issues: List of validation issues to filter
329
+ ignore_patterns: List of ignore pattern dictionaries for this requirement
330
+ filepath: Path to the policy file being checked
331
+
332
+ Returns:
333
+ Filtered list of issues (issues matching ignore patterns are removed)
334
+
335
+ Example:
336
+ A requirement can ignore specific files while other requirements check them:
337
+ - actions: ["iam:CreateRole"]
338
+ required_conditions: [...]
339
+ ignore_patterns:
340
+ - filepath_regex: ".*modules/iam-openid.*"
341
+ """
342
+ if not ignore_patterns:
343
+ return issues
344
+
345
+ return [
346
+ issue
347
+ for issue in issues
348
+ if not IgnorePatternMatcher.should_ignore_issue(issue, filepath, ignore_patterns)
349
+ ]
350
+
351
+ async def _check_policy_wide(
352
+ self,
353
+ policy: "IAMPolicy",
354
+ requirement: dict[str, Any],
355
+ fetcher: AWSServiceFetcher,
356
+ config: CheckConfig,
357
+ ) -> list[ValidationIssue]:
358
+ """
359
+ Check actions across the entire policy using all_of/any_of/none_of logic.
360
+
361
+ This enables policy-wide detection patterns:
362
+ - all_of: ALL required actions must exist somewhere in the policy
363
+ - any_of: At least ONE required action must exist somewhere in the policy
364
+ - none_of: NONE of the forbidden actions should exist in the policy
365
+ """
366
+ issues = []
367
+ actions_config = requirement.get("actions", {})
368
+ all_of = actions_config.get("all_of", [])
369
+ any_of = actions_config.get("any_of", [])
370
+ none_of = actions_config.get("none_of", [])
371
+
372
+ # Collect all actions across the entire policy
373
+ policy_wide_actions: set[str] = set()
374
+ statements_by_action: dict[str, list[tuple[int, Statement]]] = {}
375
+
376
+ for idx, statement in enumerate(policy.statement or []):
377
+ if statement.effect != "Allow":
378
+ continue
379
+
380
+ statement_actions = statement.get_actions()
381
+ policy_wide_actions.update(statement_actions)
382
+
383
+ # Track which statements grant which actions
384
+ for action in statement_actions:
385
+ if action not in statements_by_action:
386
+ statements_by_action[action] = []
387
+ statements_by_action[action].append((idx, statement))
388
+
389
+ # Check all_of: ALL required actions must exist in policy
390
+ if all_of:
391
+ all_of_result = await self._check_all_of_policy_wide(
392
+ all_of,
393
+ policy_wide_actions,
394
+ statements_by_action,
395
+ requirement,
396
+ fetcher,
397
+ config,
398
+ )
399
+ issues.extend(all_of_result)
400
+
401
+ # Check any_of: At least ONE required action must exist in policy
402
+ if any_of:
403
+ any_of_result = await self._check_any_of_policy_wide(
404
+ any_of,
405
+ policy_wide_actions,
406
+ statements_by_action,
407
+ requirement,
408
+ fetcher,
409
+ config,
410
+ )
411
+ issues.extend(any_of_result)
412
+
413
+ # Check none_of: NONE of the forbidden actions should exist in policy
414
+ if none_of:
415
+ none_of_result = await self._check_none_of_policy_wide(
416
+ none_of,
417
+ policy_wide_actions,
418
+ statements_by_action,
419
+ requirement,
420
+ config,
421
+ fetcher,
422
+ )
423
+ issues.extend(none_of_result)
424
+
425
+ return issues
426
+
427
+ async def _check_all_of_policy_wide(
428
+ self,
429
+ all_of_actions: list[str],
430
+ policy_wide_actions: set[str],
431
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
432
+ requirement: dict[str, Any],
433
+ fetcher: AWSServiceFetcher,
434
+ config: CheckConfig,
435
+ ) -> list[ValidationIssue]:
436
+ """
437
+ Check if ALL required actions exist anywhere in the policy.
438
+
439
+ For all_of, we report ONLY statements that contain ALL the required actions,
440
+ not statements that contain just some of them. This is useful for detecting
441
+ overly permissive individual statements.
442
+ """
443
+ issues = []
444
+
445
+ # First, check if ALL required actions exist somewhere in the policy
446
+ found_actions_mapping: dict[str, str] = {} # req_action -> matched_policy_action
447
+ missing_actions: list[str] = []
448
+
449
+ for req_action in all_of_actions:
450
+ action_found = False
451
+ for policy_action in policy_wide_actions:
452
+ if await self._action_matches(
453
+ policy_action, req_action, requirement.get("action_patterns", []), fetcher
454
+ ):
455
+ action_found = True
456
+ found_actions_mapping[req_action] = policy_action
457
+ break
458
+
459
+ if not action_found:
460
+ missing_actions.append(req_action)
461
+
462
+ # If not all actions exist in the policy, no issue
463
+ if missing_actions:
464
+ return issues
465
+
466
+ # ALL required actions exist in the policy
467
+ # Now find statements that have ALL of them (not just some)
468
+ statements_with_all_actions: list[tuple[int, Statement, list[str]]] = []
469
+
470
+ # Check each statement to see if it contains ALL required actions
471
+ for statement in statements_by_action.get(list(found_actions_mapping.values())[0], []):
472
+ stmt_idx, stmt = statement
473
+ stmt_actions = stmt.get_actions()
474
+
475
+ # Check if this statement has ALL required actions
476
+ has_all_actions = True
477
+ matched_actions = []
478
+
479
+ for req_action in all_of_actions:
480
+ req_action_found = False
481
+ for stmt_action in stmt_actions:
482
+ if await self._action_matches(
483
+ stmt_action, req_action, requirement.get("action_patterns", []), fetcher
484
+ ):
485
+ req_action_found = True
486
+ if stmt_action not in matched_actions:
487
+ matched_actions.append(stmt_action)
488
+ break
489
+
490
+ if not req_action_found:
491
+ has_all_actions = False
492
+ break
493
+
494
+ if has_all_actions:
495
+ statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
496
+
497
+ # Also check other statements not in the first action's list
498
+ checked_indices = {s[0] for s in statements_with_all_actions}
499
+ for policy_action, stmt_list in statements_by_action.items():
500
+ for stmt_idx, stmt in stmt_list:
501
+ if stmt_idx in checked_indices:
502
+ continue
503
+
504
+ stmt_actions = stmt.get_actions()
505
+
506
+ # Check if this statement has ALL required actions
507
+ has_all_actions = True
508
+ matched_actions = []
509
+
510
+ for req_action in all_of_actions:
511
+ req_action_found = False
512
+ for stmt_action in stmt_actions:
513
+ if await self._action_matches(
514
+ stmt_action, req_action, requirement.get("action_patterns", []), fetcher
515
+ ):
516
+ req_action_found = True
517
+ if stmt_action not in matched_actions:
518
+ matched_actions.append(stmt_action)
519
+ break
520
+
521
+ if not req_action_found:
522
+ has_all_actions = False
523
+ break
524
+
525
+ if has_all_actions:
526
+ statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
527
+ checked_indices.add(stmt_idx)
528
+
529
+ # If no statements have ALL actions, no issue to report
530
+ if not statements_with_all_actions:
531
+ return issues
532
+
533
+ # Report statements that have ALL the dangerous actions
534
+ return self._generate_policy_wide_issues(
535
+ statements_with_all_actions,
536
+ list(found_actions_mapping.values()),
537
+ requirement,
538
+ config,
539
+ "all_of",
540
+ )
541
+
542
+ async def _check_any_of_policy_wide(
543
+ self,
544
+ any_of_actions: list[str],
545
+ policy_wide_actions: set[str],
546
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
547
+ requirement: dict[str, Any],
548
+ fetcher: AWSServiceFetcher,
549
+ config: CheckConfig,
550
+ ) -> list[ValidationIssue]:
551
+ """Check if at least ONE required action exists anywhere in the policy."""
552
+ issues = []
553
+ found_actions: list[str] = []
554
+ statements_with_required_actions: list[tuple[int, Statement, list[str]]] = []
555
+
556
+ for req_action in any_of_actions:
557
+ for policy_action in policy_wide_actions:
558
+ if await self._action_matches(
559
+ policy_action, req_action, requirement.get("action_patterns", []), fetcher
560
+ ):
561
+ found_actions.append(policy_action)
562
+
563
+ # Track statements that have this action
564
+ if policy_action in statements_by_action:
565
+ for stmt_idx, stmt in statements_by_action[policy_action]:
566
+ existing = next(
567
+ (s for s in statements_with_required_actions if s[0] == stmt_idx),
568
+ None,
569
+ )
570
+ if existing:
571
+ if policy_action not in existing[2]:
572
+ existing[2].append(policy_action)
573
+ else:
574
+ statements_with_required_actions.append(
575
+ (stmt_idx, stmt, [policy_action])
576
+ )
577
+
578
+ # If no actions found, no issue
579
+ if not found_actions:
580
+ return issues
581
+
582
+ # At least one action found - validate conditions
583
+ return self._generate_policy_wide_issues(
584
+ statements_with_required_actions,
585
+ found_actions,
586
+ requirement,
587
+ config,
588
+ "any_of",
589
+ )
590
+
591
+ async def _check_none_of_policy_wide(
592
+ self,
593
+ none_of_actions: list[str],
594
+ policy_wide_actions: set[str],
595
+ statements_by_action: dict[str, list[tuple[int, Statement]]],
596
+ requirement: dict[str, Any],
597
+ config: CheckConfig,
598
+ fetcher: AWSServiceFetcher,
599
+ ) -> list[ValidationIssue]:
600
+ """Check if any forbidden actions exist in the policy."""
601
+ issues = []
602
+ forbidden_found: list[str] = []
603
+ statements_with_forbidden: list[tuple[int, Statement, list[str]]] = []
604
+
605
+ for forbidden_action in none_of_actions:
606
+ for policy_action in policy_wide_actions:
607
+ if await self._action_matches(
608
+ policy_action, forbidden_action, requirement.get("action_patterns", []), fetcher
609
+ ):
610
+ forbidden_found.append(policy_action)
611
+
612
+ # Track statements with forbidden actions
613
+ if policy_action in statements_by_action:
614
+ for stmt_idx, stmt in statements_by_action[policy_action]:
615
+ existing = next(
616
+ (s for s in statements_with_forbidden if s[0] == stmt_idx), None
617
+ )
618
+ if existing:
619
+ if policy_action not in existing[2]:
620
+ existing[2].append(policy_action)
621
+ else:
622
+ statements_with_forbidden.append((stmt_idx, stmt, [policy_action]))
623
+
624
+ # If forbidden actions found, create issues
625
+ if not forbidden_found:
626
+ return issues
627
+
628
+ description = requirement.get("description", "These actions should not be used")
629
+ severity = requirement.get("severity", self.get_severity(config))
630
+
631
+ for stmt_idx, stmt, actions in statements_with_forbidden:
632
+ actions_formatted = ", ".join(f"`{a}`" for a in actions)
633
+ statement_refs = [
634
+ f"Statement #{idx + 1}{' (SID: ' + s.sid + ')' if s.sid else ''}"
635
+ for idx, s, _ in statements_with_forbidden
636
+ ]
637
+
638
+ issues.append(
639
+ ValidationIssue(
640
+ severity=severity,
641
+ statement_sid=stmt.sid,
642
+ statement_index=stmt_idx,
643
+ issue_type="forbidden_action",
644
+ message=f"Forbidden actions {actions_formatted} found. {description}",
645
+ action=", ".join(actions),
646
+ suggestion=f"Remove these forbidden actions. Found in: {', '.join(statement_refs)}. {description}",
647
+ line_number=stmt.line_number,
648
+ field_name="action",
649
+ )
650
+ )
651
+
652
+ return issues
653
+
654
+ def _generate_policy_wide_issues(
655
+ self,
656
+ statements_with_actions: list[tuple[int, Statement, list[str]]],
657
+ found_actions: list[str],
658
+ requirement: dict[str, Any],
659
+ config: CheckConfig,
660
+ operator_type: str,
661
+ ) -> list[ValidationIssue]:
662
+ """Generate validation issues for policy-wide checks."""
663
+ issues = []
664
+ required_conditions_config = requirement.get("required_conditions", [])
665
+ description = requirement.get("description", "")
666
+ severity = requirement.get("severity", self.get_severity(config))
667
+
668
+ if not required_conditions_config:
669
+ # No conditions specified, just report that actions were found
670
+ all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(set(found_actions)))
671
+ statement_refs = [
672
+ f"Statement #{idx + 1}{' (SID: ' + stmt.sid + ')' if stmt.sid else ''}"
673
+ for idx, stmt, _ in statements_with_actions
674
+ ]
675
+
676
+ first_idx, first_stmt, _ = statements_with_actions[0]
677
+ issues.append(
678
+ ValidationIssue(
679
+ severity=severity,
680
+ statement_sid=first_stmt.sid,
681
+ statement_index=first_idx,
682
+ issue_type="action_detected",
683
+ message=f"Actions {all_actions_formatted} found across {len(statements_with_actions)} statement(s) ({operator_type}). {description}",
684
+ action=", ".join(sorted(set(found_actions))),
685
+ suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
686
+ line_number=first_stmt.line_number,
687
+ field_name="action",
688
+ )
689
+ )
690
+ return issues
691
+
692
+ # Validate conditions for each statement
693
+ for idx, statement, matching_actions in statements_with_actions:
694
+ condition_issues = self._validate_conditions(
695
+ statement,
696
+ idx,
697
+ required_conditions_config,
698
+ matching_actions,
699
+ config,
700
+ requirement,
701
+ )
702
+
703
+ # Add context
704
+ for issue in condition_issues:
705
+ issue.suggestion = (
706
+ f"{issue.suggestion}\n\n"
707
+ f"Note: Found {len(statements_with_actions)} statement(s) with these actions in the policy ({operator_type})."
708
+ )
709
+
710
+ issues.extend(condition_issues)
711
+
712
+ return issues
713
+
714
+ async def _check_per_statement(
715
+ self,
716
+ policy: "IAMPolicy",
717
+ requirement: dict[str, Any],
718
+ fetcher: AWSServiceFetcher,
719
+ config: CheckConfig,
720
+ ) -> list[ValidationIssue]:
721
+ """
722
+ Check each statement individually for matching actions (simple list format).
723
+
724
+ Used when actions are specified as a simple list (not using all_of/any_of/none_of).
725
+ """
726
+ issues = []
727
+ matching_statements: list[tuple[int, Statement, list[str]]] = []
728
+
729
+ for idx, statement in enumerate(policy.statement or []):
730
+ # Only check Allow statements
731
+ if statement.effect != "Allow":
732
+ continue
733
+
734
+ statement_actions = statement.get_actions()
735
+
736
+ # Check if this statement matches the action requirement
737
+ actions_match, matching_actions = await self._check_action_match(
738
+ statement_actions, requirement, fetcher
739
+ )
740
+
741
+ if actions_match and matching_actions:
742
+ matching_statements.append((idx, statement, matching_actions))
743
+
744
+ # If no statements match, skip this requirement
745
+ if not matching_statements:
746
+ return issues
747
+
748
+ # Now validate that ALL matching statements have the required conditions
749
+ required_conditions_config = requirement.get("required_conditions", [])
750
+ if not required_conditions_config:
751
+ # No conditions specified, just report that actions were found
752
+ description = requirement.get("description", "")
753
+ severity = requirement.get("severity", self.get_severity(config))
754
+
755
+ # Create a summary issue for all matching statements
756
+ all_actions = set()
757
+ statement_refs = []
758
+ for idx, stmt, actions in matching_statements:
759
+ all_actions.update(actions)
760
+ sid_info = f" (SID: {stmt.sid})" if stmt.sid else ""
761
+ statement_refs.append(f"Statement #{idx + 1}{sid_info}")
762
+
763
+ # Use the first matching statement's index for the issue
764
+ first_idx, first_stmt, _ = matching_statements[0]
765
+ all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(all_actions))
766
+
767
+ issues.append(
768
+ ValidationIssue(
769
+ severity=severity,
770
+ statement_sid=first_stmt.sid,
771
+ statement_index=first_idx,
772
+ issue_type="action_detected",
773
+ message=f"Actions {all_actions_formatted} found in {len(matching_statements)} statement(s). {description}",
774
+ action=", ".join(sorted(all_actions)),
775
+ suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
776
+ line_number=first_stmt.line_number,
777
+ field_name="action",
778
+ )
779
+ )
780
+ return issues
781
+
782
+ # Validate conditions for each matching statement
783
+ for idx, statement, matching_actions in matching_statements:
784
+ condition_issues = self._validate_conditions(
785
+ statement,
786
+ idx,
787
+ required_conditions_config,
788
+ matching_actions,
789
+ config,
790
+ requirement,
791
+ )
792
+
793
+ # Add context to each issue
794
+ for issue in condition_issues:
795
+ issue.suggestion = (
796
+ f"{issue.suggestion}\n\n"
797
+ f"Note: Found {len(matching_statements)} statement(s) with these actions in the policy."
798
+ )
799
+
800
+ issues.extend(condition_issues)
801
+
802
+ return issues
803
+
804
+ async def _check_action_match(
805
+ self,
806
+ statement_actions: list[str],
807
+ requirement: dict[str, Any],
808
+ fetcher: AWSServiceFetcher,
809
+ ) -> tuple[bool, list[str]]:
810
+ """
811
+ Check if statement actions match the requirement.
812
+ Supports: simple list, all_of, any_of, none_of, and action_patterns.
813
+
814
+ Returns:
815
+ (matches, list_of_matching_actions)
816
+ """
817
+ actions_config = requirement.get("actions", [])
818
+ action_patterns = requirement.get("action_patterns", [])
819
+
820
+ matching_actions: list[str] = []
821
+
822
+ # Handle simple list format (backward compatibility)
823
+ # Also handle requirements with only action_patterns (when actions is empty list)
824
+ if isinstance(actions_config, list) and (actions_config or action_patterns):
825
+ # Simple list - check if any action matches
826
+ for stmt_action in statement_actions:
827
+ if stmt_action == "*":
828
+ continue
829
+
830
+ # Check if this statement action matches any of the required actions or patterns
831
+ # Use _action_matches which handles wildcards in both statement and config
832
+ matched = False
833
+
834
+ # Check against configured actions
835
+ for required_action in actions_config:
836
+ if await self._action_matches(
837
+ stmt_action, required_action, action_patterns, fetcher
838
+ ):
839
+ matched = True
840
+ break
841
+
842
+ # If not matched by actions, check against action_patterns directly
843
+ if not matched and action_patterns:
844
+ # Check if statement action matches any of the patterns
845
+ matched = await self._action_matches(stmt_action, "", action_patterns, fetcher)
846
+
847
+ if matched and stmt_action not in matching_actions:
848
+ matching_actions.append(stmt_action)
849
+
850
+ return len(matching_actions) > 0, matching_actions
851
+
852
+ # Handle all_of/any_of/none_of format
853
+ if isinstance(actions_config, dict):
854
+ all_of = actions_config.get("all_of", [])
855
+ any_of = actions_config.get("any_of", [])
856
+ none_of = actions_config.get("none_of", [])
857
+
858
+ # Check all_of: ALL specified actions must be in statement
859
+ if all_of:
860
+ all_present = True
861
+ for req_action in all_of:
862
+ found = False
863
+ for stmt_action in statement_actions:
864
+ if await self._action_matches(
865
+ stmt_action, req_action, action_patterns, fetcher
866
+ ):
867
+ found = True
868
+ break
869
+ if not found:
870
+ all_present = False
871
+ break
872
+
873
+ if not all_present:
874
+ return False, []
875
+
876
+ # Collect matching actions
877
+ for stmt_action in statement_actions:
878
+ for req_action in all_of:
879
+ if await self._action_matches(
880
+ stmt_action, req_action, action_patterns, fetcher
881
+ ):
882
+ if stmt_action not in matching_actions:
883
+ matching_actions.append(stmt_action)
884
+
885
+ # Check any_of: At least ONE specified action must be in statement
886
+ if any_of:
887
+ any_present = False
888
+ for stmt_action in statement_actions:
889
+ for req_action in any_of:
890
+ if await self._action_matches(
891
+ stmt_action, req_action, action_patterns, fetcher
892
+ ):
893
+ any_present = True
894
+ if stmt_action not in matching_actions:
895
+ matching_actions.append(stmt_action)
896
+
897
+ if not any_present:
898
+ return False, []
899
+
900
+ # Check none_of: NONE of the specified actions should be in statement
901
+ if none_of:
902
+ forbidden_actions = []
903
+ for stmt_action in statement_actions:
904
+ for forbidden_action in none_of:
905
+ if await self._action_matches(
906
+ stmt_action, forbidden_action, action_patterns, fetcher
907
+ ):
908
+ forbidden_actions.append(stmt_action)
909
+
910
+ # If forbidden actions are found, this is a match for flagging
911
+ if forbidden_actions:
912
+ return True, forbidden_actions
913
+
914
+ return len(matching_actions) > 0, matching_actions
915
+
916
+ return False, []
917
+
918
+ async def _action_matches(
919
+ self,
920
+ statement_action: str,
921
+ required_action: str,
922
+ patterns: list[str],
923
+ fetcher: AWSServiceFetcher,
924
+ ) -> bool:
925
+ """
926
+ Check if a statement action matches a required action or pattern.
927
+ Supports:
928
+ - Exact matches: "s3:GetObject"
929
+ - AWS wildcards in both statement and required actions: "s3:*", "s3:Get*", "iam:Creat*"
930
+ - Regex patterns: "^s3:Get.*", "^iam:Delete.*"
931
+
932
+ This method handles bidirectional wildcard matching using real AWS actions from the fetcher:
933
+ - statement_action="iam:Create*" matches required_action="iam:CreateUser"
934
+ - statement_action="iam:C*" matches pattern="^iam:Create" (by checking actual AWS actions)
935
+ """
936
+ if statement_action == "*":
937
+ return False
938
+
939
+ # Exact match
940
+ if statement_action == required_action:
941
+ return True
942
+
943
+ # AWS wildcard match in required_action (e.g., "s3:*", "s3:Get*")
944
+ if "*" in required_action:
945
+ # Convert AWS wildcard to regex and cache compilation
946
+ wildcard_pattern = required_action.replace("*", ".*").replace("?", ".")
947
+ try:
948
+ compiled_pattern = compile_and_cache(f"^{wildcard_pattern}$")
949
+ if compiled_pattern.match(statement_action):
950
+ return True
951
+ except re.error:
952
+ # Invalid regex pattern - skip this match attempt
953
+ pass
954
+
955
+ # AWS wildcard match in statement_action (e.g., "iam:Creat*" in policy)
956
+ # Check if this wildcard would grant access to actions matching our patterns
957
+ if "*" in statement_action:
958
+ # Convert statement wildcard to regex pattern
959
+ stmt_wildcard_pattern = statement_action.replace("*", ".*").replace("?", ".")
960
+
961
+ # Check if statement wildcard overlaps with required action
962
+ if "*" not in required_action:
963
+ # Required action is specific (e.g., "iam:CreateUser")
964
+ # Check if statement wildcard would grant it
965
+ try:
966
+ compiled_pattern = compile_and_cache(f"^{stmt_wildcard_pattern}$")
967
+ if compiled_pattern.match(required_action):
968
+ return True
969
+ except re.error:
970
+ # Invalid regex pattern - skip this match attempt
971
+ pass
972
+
973
+ # Check if statement wildcard overlaps with any of our action patterns
974
+ # Strategy: Use real AWS actions from the fetcher instead of hardcoded guesses
975
+ # For example: "iam:C*" should match pattern "^iam:Create" because:
976
+ # - "iam:C*" grants iam:CreateUser, iam:CreateRole, etc. (from AWS)
977
+ # - "^iam:Create" pattern is meant to catch iam:CreateUser, iam:CreateRole, etc.
978
+ # - Therefore they overlap
979
+ if patterns:
980
+ try:
981
+ # Parse the service from the wildcard action
982
+ service_prefix, _ = fetcher.parse_action(statement_action)
983
+
984
+ # Fetch the real list of actions for this service
985
+ service_detail = await fetcher.fetch_service_by_name(service_prefix)
986
+ available_actions = list(service_detail.actions.keys())
987
+
988
+ # Find which actual AWS actions the wildcard would grant
989
+ _, granted_actions = fetcher.match_wildcard_action(
990
+ statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
991
+ available_actions,
992
+ )
993
+
994
+ # Check if any of the granted actions match our patterns
995
+ for granted_action in granted_actions:
996
+ full_granted_action = f"{service_prefix}:{granted_action}"
997
+ for pattern in patterns:
998
+ try:
999
+ compiled_pattern = compile_and_cache(pattern)
1000
+ if compiled_pattern.match(full_granted_action):
1001
+ return True
1002
+ except re.error:
1003
+ continue
1004
+
1005
+ except (ValueError, Exception): # pylint: disable=broad-exception-caught
1006
+ # If we can't fetch the service or parse the action, fall back to prefix matching
1007
+ stmt_prefix = statement_action.rstrip("*")
1008
+ for pattern in patterns:
1009
+ try:
1010
+ compiled_pattern = compile_and_cache(pattern)
1011
+ if compiled_pattern.match(stmt_prefix):
1012
+ return True
1013
+ except re.error:
1014
+ continue
1015
+
1016
+ # Regex pattern match (from action_patterns config)
1017
+ for pattern in patterns:
1018
+ try:
1019
+ compiled_pattern = compile_and_cache(pattern)
1020
+ if compiled_pattern.match(statement_action):
1021
+ return True
1022
+ except re.error:
1023
+ continue
1024
+
1025
+ return False
1026
+
1027
+ def _validate_conditions(
1028
+ self,
1029
+ statement: Statement,
1030
+ statement_idx: int,
1031
+ required_conditions_config: Any,
1032
+ matching_actions: list[str],
1033
+ config: CheckConfig,
1034
+ requirement: dict[str, Any] | None = None,
1035
+ ) -> list[ValidationIssue]:
1036
+ """
1037
+ Validate that required conditions are present.
1038
+ Supports: simple list, all_of, any_of formats.
1039
+ Can use per-requirement severity override from requirement['severity'].
1040
+ """
1041
+ issues: list[ValidationIssue] = []
1042
+
1043
+ # Handle simple list format (backward compatibility)
1044
+ if isinstance(required_conditions_config, list):
1045
+ for condition_requirement in required_conditions_config:
1046
+ if not self._has_condition_requirement(statement, condition_requirement):
1047
+ issues.append(
1048
+ self._create_issue(
1049
+ statement,
1050
+ statement_idx,
1051
+ condition_requirement,
1052
+ matching_actions,
1053
+ config,
1054
+ requirement=requirement,
1055
+ )
1056
+ )
1057
+ return issues
1058
+
1059
+ # Handle all_of/any_of/none_of format
1060
+ if isinstance(required_conditions_config, dict):
1061
+ all_of = required_conditions_config.get("all_of", [])
1062
+ any_of = required_conditions_config.get("any_of", [])
1063
+ none_of = required_conditions_config.get("none_of", [])
1064
+
1065
+ # Validate all_of: ALL conditions must be present
1066
+ if all_of:
1067
+ for condition_requirement in all_of:
1068
+ if not self._has_condition_requirement(statement, condition_requirement):
1069
+ issues.append(
1070
+ self._create_issue(
1071
+ statement,
1072
+ statement_idx,
1073
+ condition_requirement,
1074
+ matching_actions,
1075
+ config,
1076
+ requirement_type="all_of",
1077
+ requirement=requirement,
1078
+ )
1079
+ )
1080
+
1081
+ # Validate any_of: At least ONE condition must be present
1082
+ if any_of:
1083
+ any_present = any(
1084
+ self._has_condition_requirement(statement, cond_req) for cond_req in any_of
1085
+ )
1086
+
1087
+ if not any_present:
1088
+ # Check if requirement has custom message/suggestion/example
1089
+ custom_message = requirement.get("message") if requirement else None
1090
+ custom_suggestion = requirement.get("suggestion") if requirement else None
1091
+ custom_example = requirement.get("example") if requirement else None
1092
+
1093
+ if custom_message:
1094
+ # Use fully custom message/suggestion/example from requirement
1095
+ message = custom_message
1096
+ suggestion = custom_suggestion or ""
1097
+ example = custom_example or ""
1098
+ else:
1099
+ # Generate default message and build suggestion from conditions
1100
+ condition_keys = []
1101
+ for cond in any_of:
1102
+ if "all_of" in cond:
1103
+ # Nested all_of - collect all condition keys
1104
+ nested_keys = [
1105
+ c.get("condition_key", "unknown") for c in cond["all_of"]
1106
+ ]
1107
+ condition_keys.append(
1108
+ f"({' + '.join(f'`{k}`' for k in nested_keys)})"
1109
+ )
1110
+ else:
1111
+ # Simple condition
1112
+ condition_keys.append(f"`{cond.get('condition_key', 'unknown')}`")
1113
+ condition_keys_formatted = " OR ".join(condition_keys)
1114
+ matching_actions_formatted = ", ".join(f"`{a}`" for a in matching_actions)
1115
+
1116
+ message = (
1117
+ f"Actions {matching_actions_formatted} require at least ONE of these conditions: "
1118
+ f"{condition_keys_formatted}"
1119
+ )
1120
+
1121
+ # Build suggestion and examples from conditions
1122
+ suggestion, example = self._build_any_of_suggestion(any_of)
1123
+
1124
+ issues.append(
1125
+ ValidationIssue(
1126
+ severity=self.get_severity(config),
1127
+ statement_sid=statement.sid,
1128
+ statement_index=statement_idx,
1129
+ issue_type="missing_required_condition_any_of",
1130
+ message=message,
1131
+ action=", ".join(matching_actions),
1132
+ suggestion=suggestion,
1133
+ example=example if example else None,
1134
+ line_number=statement.line_number,
1135
+ field_name="condition",
1136
+ )
1137
+ )
1138
+
1139
+ # Validate none_of: NONE of these conditions should be present
1140
+ if none_of:
1141
+ for condition_requirement in none_of:
1142
+ if self._has_condition_requirement(statement, condition_requirement):
1143
+ issues.append(
1144
+ self._create_none_of_issue(
1145
+ statement,
1146
+ statement_idx,
1147
+ condition_requirement,
1148
+ matching_actions,
1149
+ config,
1150
+ )
1151
+ )
1152
+
1153
+ return issues
1154
+
1155
+ def _has_condition_requirement(
1156
+ self, statement: Statement, condition_requirement: dict[str, Any]
1157
+ ) -> bool:
1158
+ """Check if statement has the required condition."""
1159
+ condition_key = condition_requirement.get("condition_key")
1160
+ if not condition_key:
1161
+ return True # No condition key specified, skip
1162
+
1163
+ operator = condition_requirement.get("operator")
1164
+ expected_value = condition_requirement.get("expected_value")
1165
+
1166
+ return self._has_condition(statement, condition_key, operator, expected_value)
1167
+
1168
+ def _has_condition(
1169
+ self,
1170
+ statement: Statement,
1171
+ condition_key: str,
1172
+ operator: str | None = None,
1173
+ expected_value: Any = None,
1174
+ ) -> bool:
1175
+ """
1176
+ Check if statement has the specified condition key.
1177
+
1178
+ Args:
1179
+ statement: The IAM policy statement
1180
+ condition_key: The condition key to look for
1181
+ operator: Optional specific operator (e.g., "StringEquals")
1182
+ expected_value: Optional expected value for the condition
1183
+
1184
+ Returns:
1185
+ True if condition is present (and matches expected value if specified)
1186
+ """
1187
+ if not statement.condition:
1188
+ return False
1189
+
1190
+ # If operator specified, only check that operator
1191
+ operators_to_check = [operator] if operator else list(statement.condition.keys())
1192
+
1193
+ # Look through specified condition operators
1194
+ for op in operators_to_check:
1195
+ if op not in statement.condition:
1196
+ continue
1197
+
1198
+ conditions = statement.condition[op]
1199
+ if isinstance(conditions, dict):
1200
+ if condition_key in conditions:
1201
+ # If no expected value specified, just presence is enough
1202
+ if expected_value is None:
1203
+ return True
1204
+
1205
+ # Check if the value matches
1206
+ actual_value = conditions[condition_key]
1207
+
1208
+ # Handle boolean values
1209
+ if isinstance(expected_value, bool):
1210
+ if isinstance(actual_value, bool):
1211
+ return actual_value == expected_value
1212
+ if isinstance(actual_value, str):
1213
+ return actual_value.lower() == str(expected_value).lower()
1214
+
1215
+ # Handle exact matches
1216
+ if actual_value == expected_value:
1217
+ return True
1218
+
1219
+ # Handle list values (actual can be string or list)
1220
+ if isinstance(expected_value, list):
1221
+ if isinstance(actual_value, list):
1222
+ return set(expected_value) == set(actual_value)
1223
+ if actual_value in expected_value:
1224
+ return True
1225
+
1226
+ # Handle string matches for variable references like ${aws:PrincipalTag/owner}
1227
+ if str(actual_value) == str(expected_value):
1228
+ return True
1229
+
1230
+ return False
1231
+
1232
+ def _create_issue(
1233
+ self,
1234
+ statement: Statement,
1235
+ statement_idx: int,
1236
+ condition_requirement: dict[str, Any],
1237
+ matching_actions: list[str],
1238
+ config: CheckConfig,
1239
+ requirement_type: str = "required",
1240
+ requirement: dict[str, Any] | None = None,
1241
+ ) -> ValidationIssue:
1242
+ """Create a validation issue for a missing condition.
1243
+
1244
+ Severity precedence:
1245
+ 1. Individual condition requirement's severity (condition_requirement['severity'])
1246
+ 2. Parent requirement's severity (requirement['severity'])
1247
+ 3. Global check severity (config.severity)
1248
+ """
1249
+ condition_key = condition_requirement.get("condition_key", "unknown")
1250
+ description = condition_requirement.get("description", "")
1251
+ expected_value = condition_requirement.get("expected_value")
1252
+ example = condition_requirement.get("example", "")
1253
+ operator = condition_requirement.get("operator", "StringEquals")
1254
+
1255
+ message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
1256
+
1257
+ # Determine severity with precedence: condition > requirement > global
1258
+ severity = (
1259
+ condition_requirement.get("severity") # Condition-level override
1260
+ or (requirement.get("severity") if requirement else None) # Requirement-level override
1261
+ or self.get_severity(config) # Global check severity
1262
+ )
1263
+
1264
+ suggestion_text, example_code = self._build_suggestion(
1265
+ condition_key, description, example, expected_value, operator
1266
+ )
1267
+
1268
+ matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
1269
+ return ValidationIssue(
1270
+ severity=severity,
1271
+ statement_sid=statement.sid,
1272
+ statement_index=statement_idx,
1273
+ issue_type="missing_required_condition",
1274
+ message=f"{message_prefix} Action(s) {matching_actions_str} require condition `{condition_key}`",
1275
+ action=", ".join(matching_actions),
1276
+ condition_key=condition_key,
1277
+ suggestion=suggestion_text,
1278
+ example=example_code,
1279
+ line_number=statement.line_number,
1280
+ field_name="condition",
1281
+ )
1282
+
1283
+ def _build_suggestion(
1284
+ self,
1285
+ condition_key: str,
1286
+ description: str,
1287
+ example: str,
1288
+ expected_value: Any = None,
1289
+ operator: str = "StringEquals",
1290
+ ) -> tuple[str, str]:
1291
+ """Build suggestion and example for adding the missing condition.
1292
+
1293
+ Returns:
1294
+ Tuple of (suggestion_text, example_code)
1295
+ """
1296
+ suggestion = description if description else f"Add condition: `{condition_key}`"
1297
+
1298
+ # Build example based on condition key type
1299
+ if example:
1300
+ example_code = example
1301
+ else:
1302
+ # Auto-generate example
1303
+ example_lines = [f' "{operator}": {{']
1304
+
1305
+ if isinstance(expected_value, list):
1306
+ value_str = (
1307
+ "["
1308
+ + ", ".join(
1309
+ [
1310
+ f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
1311
+ for v in expected_value
1312
+ ]
1313
+ )
1314
+ + "]"
1315
+ )
1316
+ elif expected_value is not None:
1317
+ # Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
1318
+ if str(expected_value).startswith("${"):
1319
+ value_str = f'"{expected_value}"'
1320
+ elif isinstance(expected_value, bool):
1321
+ value_str = str(expected_value).lower()
1322
+ else:
1323
+ value_str = f'"{expected_value}"'
1324
+ else:
1325
+ value_str = '"<value>"'
1326
+
1327
+ example_lines.append(f' "{condition_key}": {value_str}')
1328
+ example_lines.append(" }")
1329
+
1330
+ example_code = "\n".join(example_lines)
1331
+
1332
+ return suggestion, example_code
1333
+
1334
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> tuple[str, str]:
1335
+ """Build suggestion and combined examples for any_of conditions.
1336
+
1337
+ Always uses clean formatting without "Option X" prefixes.
1338
+ Uses either:
1339
+ - 'message' field if provided (custom message)
1340
+ - 'description' field if provided (displays as: `condition_key` - description)
1341
+ - Just 'condition_key' if neither message nor description provided
1342
+
1343
+ Returns:
1344
+ Tuple of (suggestion_text, combined_examples)
1345
+ """
1346
+ suggestions = []
1347
+ examples = []
1348
+
1349
+ suggestions.append("Add at least ONE of these conditions:")
1350
+
1351
+ for cond in any_of_conditions:
1352
+ # Handle nested all_of blocks
1353
+ if "all_of" in cond:
1354
+ # Nested all_of - show all required conditions together
1355
+ all_of_list = cond["all_of"]
1356
+ condition_keys = [c.get("condition_key", "unknown") for c in all_of_list]
1357
+ condition_keys_formatted = " + ".join(f"`{k}`" for k in condition_keys)
1358
+
1359
+ # Check for custom message first
1360
+ custom_message = cond.get("message")
1361
+ if custom_message:
1362
+ suggestions.append(f"\n- {custom_message}")
1363
+ else:
1364
+ # Use description from first condition or combine them
1365
+ descriptions = [
1366
+ c.get("description", "") for c in all_of_list if c.get("description")
1367
+ ]
1368
+ if descriptions:
1369
+ suggestions.append(f"\n- {condition_keys_formatted} - {descriptions[0]}")
1370
+ else:
1371
+ suggestions.append(f"\n- {condition_keys_formatted} (both required)")
1372
+
1373
+ # Collect example from first condition that has one
1374
+ for c in all_of_list:
1375
+ if c.get("example"):
1376
+ examples.append(c["example"])
1377
+ break
1378
+ else:
1379
+ # Simple condition - check for message, description, or just condition_key
1380
+ custom_message = cond.get("message")
1381
+ if custom_message:
1382
+ # Use custom message directly
1383
+ suggestions.append(f"\n- {custom_message}")
1384
+ else:
1385
+ # Use description if available
1386
+ condition_key = cond.get("condition_key", "unknown")
1387
+ description = cond.get("description", "")
1388
+ expected_value = cond.get("expected_value")
1389
+
1390
+ if description:
1391
+ # Format: - `condition_key` - description
1392
+ suggestions.append(f"\n- `{condition_key}` - {description}")
1393
+ else:
1394
+ # Format: - `condition_key` (with expected value if present)
1395
+ suggestion_line = f"\n- `{condition_key}`"
1396
+ if expected_value is not None:
1397
+ suggestion_line += f" (value: `{expected_value}`)"
1398
+ suggestions.append(suggestion_line)
1399
+
1400
+ # Collect example if present (no prefix)
1401
+ if cond.get("example"):
1402
+ examples.append(cond["example"])
1403
+
1404
+ suggestion_text = "".join(suggestions)
1405
+ combined_examples = "\n\n".join(examples) if examples else ""
1406
+
1407
+ return suggestion_text, combined_examples
1408
+
1409
+ def _create_none_of_issue(
1410
+ self,
1411
+ statement: Statement,
1412
+ statement_idx: int,
1413
+ condition_requirement: dict[str, Any],
1414
+ matching_actions: list[str],
1415
+ config: CheckConfig,
1416
+ ) -> ValidationIssue:
1417
+ """Create a validation issue for a forbidden condition that is present."""
1418
+ condition_key = condition_requirement.get("condition_key", "unknown")
1419
+ description = condition_requirement.get("description", "")
1420
+ expected_value = condition_requirement.get("expected_value")
1421
+
1422
+ matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
1423
+ message = f"FORBIDDEN: Action(s) `{matching_actions_str}` must NOT have condition `{condition_key}`"
1424
+ if expected_value is not None:
1425
+ message += f" with value `{expected_value}`"
1426
+
1427
+ suggestion = f"Remove the `{condition_key}` condition from the statement"
1428
+ if description:
1429
+ suggestion += f". {description}"
1430
+
1431
+ return ValidationIssue(
1432
+ severity=self.get_severity(config),
1433
+ statement_sid=statement.sid,
1434
+ statement_index=statement_idx,
1435
+ issue_type="forbidden_condition_present",
1436
+ message=message,
1437
+ action=", ".join(matching_actions),
1438
+ condition_key=condition_key,
1439
+ suggestion=suggestion,
1440
+ line_number=statement.line_number,
1441
+ field_name="condition",
1442
+ )