iam-policy-validator 1.7.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.

Potentially problematic release.


This version of iam-policy-validator might be problematic. Click here for more details.

Files changed (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.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 +7 -0
  9. iam_validator/checks/__init__.py +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. iam_validator/utils/regex.py +206 -0
@@ -0,0 +1,884 @@
1
+ """
2
+ Action-Specific Condition Enforcement Check (Unified)
3
+
4
+ This built-in check ensures that specific actions have required conditions.
5
+ Supports ALL types of conditions: MFA, IP, VPC, time, tags, encryption, etc.
6
+
7
+ Supports advanced "all_of" and "any_of" logic for both actions and conditions.
8
+ Supports both STATEMENT-LEVEL and POLICY-LEVEL enforcement.
9
+
10
+ Common use cases:
11
+ - iam:PassRole must have iam:PassedToService condition
12
+ - Sensitive actions must have MFA conditions
13
+ - Actions must have source IP restrictions
14
+ - Resources must have required tags
15
+ - Combine multiple conditions (MFA + IP + Tags)
16
+ - Policy-level: Ensure ALL statements granting certain actions have MFA
17
+
18
+ Configuration in iam-validator.yaml:
19
+
20
+ checks:
21
+ action_condition_enforcement:
22
+ enabled: true
23
+ severity: error
24
+ description: "Enforce specific conditions for specific actions"
25
+
26
+ # STATEMENT-LEVEL: Check individual statements (default)
27
+ action_condition_requirements:
28
+ # BASIC: Simple action with required condition
29
+ - actions:
30
+ - "iam:PassRole"
31
+ required_conditions:
32
+ - condition_key: "iam:PassedToService"
33
+ description: "Specify which AWS services can use the passed role"
34
+
35
+ # MFA + IP restrictions
36
+ - actions:
37
+ - "iam:DeleteUser"
38
+ required_conditions:
39
+ all_of:
40
+ - condition_key: "aws:MultiFactorAuthPresent"
41
+ expected_value: true
42
+ - condition_key: "aws:SourceIp"
43
+
44
+ # EC2 with TAGS + MFA + Region
45
+ - actions:
46
+ - "ec2:RunInstances"
47
+ required_conditions:
48
+ all_of:
49
+ - condition_key: "aws:MultiFactorAuthPresent"
50
+ expected_value: true
51
+ - condition_key: "aws:RequestTag/Environment"
52
+ operator: "StringEquals"
53
+ expected_value: ["Production", "Staging", "Development"]
54
+ - condition_key: "aws:RequestTag/Owner"
55
+ - condition_key: "aws:RequestedRegion"
56
+ expected_value: ["us-east-1", "us-west-2"]
57
+
58
+ # Principal-to-resource tag matching
59
+ - actions:
60
+ - "ec2:RunInstances"
61
+ required_conditions:
62
+ - condition_key: "aws:ResourceTag/owner"
63
+ operator: "StringEquals"
64
+ expected_value: "${aws:PrincipalTag/owner}"
65
+ description: "Resource owner must match principal's owner tag"
66
+
67
+ # Complex: all_of + any_of for actions and conditions
68
+ - actions:
69
+ any_of:
70
+ - "cloudformation:CreateStack"
71
+ - "cloudformation:UpdateStack"
72
+ required_conditions:
73
+ all_of:
74
+ - condition_key: "aws:MultiFactorAuthPresent"
75
+ expected_value: true
76
+ - condition_key: "aws:RequestTag/Environment"
77
+ any_of:
78
+ - condition_key: "aws:SourceIp"
79
+ - condition_key: "aws:SourceVpce"
80
+
81
+ # none_of for conditions: Ensure certain conditions are NOT present
82
+ - actions:
83
+ - "s3:GetObject"
84
+ required_conditions:
85
+ none_of:
86
+ - condition_key: "aws:SecureTransport"
87
+ expected_value: false
88
+ description: "Ensure insecure transport is never allowed"
89
+
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
101
+ - actions:
102
+ any_of:
103
+ - "iam:CreateUser"
104
+ - "iam:AttachUserPolicy"
105
+ - "iam:PutUserPolicy"
106
+ scope: "policy"
107
+ required_conditions:
108
+ - condition_key: "aws:MultiFactorAuthPresent"
109
+ expected_value: true
110
+ description: "Privilege escalation actions require MFA across entire policy"
111
+ severity: "critical"
112
+
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
127
+ - actions:
128
+ all_of:
129
+ - "iam:CreateAccessKey"
130
+ - "iam:UpdateAccessKey"
131
+ scope: "policy"
132
+ severity: "critical"
133
+ description: "Dangerous combination of actions detected in policy"
134
+ """
135
+
136
+ import re
137
+ from typing import TYPE_CHECKING, Any
138
+
139
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
140
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
141
+ from iam_validator.core.models import Statement, ValidationIssue
142
+
143
+ if TYPE_CHECKING:
144
+ from iam_validator.core.models import IAMPolicy
145
+
146
+
147
+ class ActionConditionEnforcementCheck(PolicyCheck):
148
+ """Enforces specific condition requirements for specific actions with all_of/any_of support."""
149
+
150
+ @property
151
+ def check_id(self) -> str:
152
+ return "action_condition_enforcement"
153
+
154
+ @property
155
+ def description(self) -> str:
156
+ return "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
157
+
158
+ @property
159
+ def default_severity(self) -> str:
160
+ return "error"
161
+
162
+ async def execute(
163
+ self,
164
+ statement: Statement,
165
+ statement_idx: int,
166
+ fetcher: AWSServiceFetcher,
167
+ config: CheckConfig,
168
+ ) -> list[ValidationIssue]:
169
+ """Execute statement-level condition enforcement check."""
170
+ issues = []
171
+
172
+ # Only check Allow statements
173
+ if statement.effect != "Allow":
174
+ return issues
175
+
176
+ # Get action condition requirements from config
177
+ action_condition_requirements = config.config.get("action_condition_requirements", [])
178
+ if not action_condition_requirements:
179
+ return issues
180
+
181
+ statement_actions = statement.get_actions()
182
+
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
+ )
189
+
190
+ if not actions_match:
191
+ continue
192
+
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
+ )
211
+ )
212
+ continue
213
+
214
+ # Actions match - now validate required conditions
215
+ required_conditions_config = requirement.get("required_conditions", [])
216
+ if not required_conditions_config:
217
+ continue
218
+
219
+ # Check if conditions are in all_of/any_of/none_of format or simple list
220
+ condition_issues = self._validate_conditions(
221
+ statement,
222
+ statement_idx,
223
+ required_conditions_config,
224
+ matching_actions,
225
+ config,
226
+ requirement, # Pass the full requirement for severity override
227
+ )
228
+
229
+ issues.extend(condition_issues)
230
+
231
+ return issues
232
+
233
+ async def execute_policy(
234
+ self,
235
+ policy: "IAMPolicy",
236
+ policy_file: str,
237
+ fetcher: AWSServiceFetcher,
238
+ config: CheckConfig,
239
+ **kwargs,
240
+ ) -> list[ValidationIssue]:
241
+ """
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.)
258
+
259
+ Returns:
260
+ List of ValidationIssue objects found by this check
261
+ """
262
+ del policy_file, kwargs # Not used in current implementation
263
+ issues = []
264
+
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
279
+
280
+ statement_actions = statement.get_actions()
281
+
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
+ )
286
+
287
+ if actions_match and matching_actions:
288
+ matching_statements.append((idx, statement, matching_actions))
289
+
290
+ # If no statements match, skip this requirement
291
+ if not matching_statements:
292
+ continue
293
+
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
+ )
323
+ )
324
+ continue
325
+
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
+ )
336
+
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
+ )
346
+
347
+ issues.extend(condition_issues)
348
+
349
+ return issues
350
+
351
+ async def _check_action_match(
352
+ self, statement_actions: list[str], requirement: dict[str, Any], fetcher: AWSServiceFetcher
353
+ ) -> tuple[bool, list[str]]:
354
+ """
355
+ Check if statement actions match the requirement.
356
+ Supports: simple list, all_of, any_of, none_of, and action_patterns.
357
+
358
+ Returns:
359
+ (matches, list_of_matching_actions)
360
+ """
361
+ actions_config = requirement.get("actions", [])
362
+ action_patterns = requirement.get("action_patterns", [])
363
+
364
+ matching_actions: list[str] = []
365
+
366
+ # Handle simple list format (backward compatibility)
367
+ if isinstance(actions_config, list) and actions_config:
368
+ # Simple list - check if any action matches
369
+ for stmt_action in statement_actions:
370
+ if stmt_action == "*":
371
+ continue
372
+
373
+ # Check if this statement action matches any of the required actions or patterns
374
+ # Use _action_matches which handles wildcards in both statement and config
375
+ matched = False
376
+
377
+ # Check against configured actions
378
+ for required_action in actions_config:
379
+ if await self._action_matches(
380
+ stmt_action, required_action, action_patterns, fetcher
381
+ ):
382
+ matched = True
383
+ break
384
+
385
+ # If not matched by actions, check if wildcard overlaps with patterns
386
+ if not matched and "*" in stmt_action:
387
+ # For wildcards, also check pattern overlap directly
388
+ matched = await self._action_matches(stmt_action, "", action_patterns, fetcher)
389
+
390
+ if matched and stmt_action not in matching_actions:
391
+ matching_actions.append(stmt_action)
392
+
393
+ return len(matching_actions) > 0, matching_actions
394
+
395
+ # Handle all_of/any_of/none_of format
396
+ if isinstance(actions_config, dict):
397
+ all_of = actions_config.get("all_of", [])
398
+ any_of = actions_config.get("any_of", [])
399
+ none_of = actions_config.get("none_of", [])
400
+
401
+ # Check all_of: ALL specified actions must be in statement
402
+ if all_of:
403
+ all_present = True
404
+ for req_action in all_of:
405
+ found = False
406
+ for stmt_action in statement_actions:
407
+ if await self._action_matches(
408
+ stmt_action, req_action, action_patterns, fetcher
409
+ ):
410
+ found = True
411
+ break
412
+ if not found:
413
+ all_present = False
414
+ break
415
+
416
+ if not all_present:
417
+ return False, []
418
+
419
+ # Collect matching actions
420
+ for stmt_action in statement_actions:
421
+ for req_action in all_of:
422
+ if await self._action_matches(
423
+ stmt_action, req_action, action_patterns, fetcher
424
+ ):
425
+ if stmt_action not in matching_actions:
426
+ matching_actions.append(stmt_action)
427
+
428
+ # Check any_of: At least ONE specified action must be in statement
429
+ if any_of:
430
+ any_present = False
431
+ for stmt_action in statement_actions:
432
+ for req_action in any_of:
433
+ if await self._action_matches(
434
+ stmt_action, req_action, action_patterns, fetcher
435
+ ):
436
+ any_present = True
437
+ if stmt_action not in matching_actions:
438
+ matching_actions.append(stmt_action)
439
+
440
+ if not any_present:
441
+ return False, []
442
+
443
+ # Check none_of: NONE of the specified actions should be in statement
444
+ if none_of:
445
+ forbidden_actions = []
446
+ for stmt_action in statement_actions:
447
+ for forbidden_action in none_of:
448
+ if await self._action_matches(
449
+ stmt_action, forbidden_action, action_patterns, fetcher
450
+ ):
451
+ forbidden_actions.append(stmt_action)
452
+
453
+ # If forbidden actions are found, this is a match for flagging
454
+ if forbidden_actions:
455
+ return True, forbidden_actions
456
+
457
+ return len(matching_actions) > 0, matching_actions
458
+
459
+ return False, []
460
+
461
+ async def _action_matches(
462
+ self,
463
+ statement_action: str,
464
+ required_action: str,
465
+ patterns: list[str],
466
+ fetcher: AWSServiceFetcher,
467
+ ) -> bool:
468
+ """
469
+ Check if a statement action matches a required action or pattern.
470
+ Supports:
471
+ - Exact matches: "s3:GetObject"
472
+ - AWS wildcards in both statement and required actions: "s3:*", "s3:Get*", "iam:Creat*"
473
+ - Regex patterns: "^s3:Get.*", "^iam:Delete.*"
474
+
475
+ This method handles bidirectional wildcard matching using real AWS actions from the fetcher:
476
+ - statement_action="iam:Create*" matches required_action="iam:CreateUser"
477
+ - statement_action="iam:C*" matches pattern="^iam:Create" (by checking actual AWS actions)
478
+ """
479
+ if statement_action == "*":
480
+ return False
481
+
482
+ # Exact match
483
+ if statement_action == required_action:
484
+ return True
485
+
486
+ # AWS wildcard match in required_action (e.g., "s3:*", "s3:Get*")
487
+ if "*" in required_action:
488
+ # Convert AWS wildcard to regex
489
+ wildcard_pattern = required_action.replace("*", ".*").replace("?", ".")
490
+ try:
491
+ if re.match(f"^{wildcard_pattern}$", statement_action):
492
+ return True
493
+ except re.error:
494
+ pass
495
+
496
+ # AWS wildcard match in statement_action (e.g., "iam:Creat*" in policy)
497
+ # Check if this wildcard would grant access to actions matching our patterns
498
+ if "*" in statement_action:
499
+ # Convert statement wildcard to regex pattern
500
+ stmt_wildcard_pattern = statement_action.replace("*", ".*").replace("?", ".")
501
+
502
+ # Check if statement wildcard overlaps with required action
503
+ if "*" not in required_action:
504
+ # Required action is specific (e.g., "iam:CreateUser")
505
+ # Check if statement wildcard would grant it
506
+ try:
507
+ if re.match(f"^{stmt_wildcard_pattern}$", required_action):
508
+ return True
509
+ except re.error:
510
+ pass
511
+
512
+ # Check if statement wildcard overlaps with any of our action patterns
513
+ # Strategy: Use real AWS actions from the fetcher instead of hardcoded guesses
514
+ # For example: "iam:C*" should match pattern "^iam:Create" because:
515
+ # - "iam:C*" grants iam:CreateUser, iam:CreateRole, etc. (from AWS)
516
+ # - "^iam:Create" pattern is meant to catch iam:CreateUser, iam:CreateRole, etc.
517
+ # - Therefore they overlap
518
+ if patterns:
519
+ try:
520
+ # Parse the service from the wildcard action
521
+ service_prefix, _ = fetcher.parse_action(statement_action)
522
+
523
+ # Fetch the real list of actions for this service
524
+ service_detail = await fetcher.fetch_service_by_name(service_prefix)
525
+ available_actions = list(service_detail.actions.keys())
526
+
527
+ # Find which actual AWS actions the wildcard would grant
528
+ _, granted_actions = fetcher._match_wildcard_action(
529
+ statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
530
+ available_actions,
531
+ )
532
+
533
+ # Check if any of the granted actions match our patterns
534
+ for granted_action in granted_actions:
535
+ full_granted_action = f"{service_prefix}:{granted_action}"
536
+ for pattern in patterns:
537
+ try:
538
+ if re.match(pattern, full_granted_action):
539
+ return True
540
+ except re.error:
541
+ continue
542
+
543
+ except (ValueError, Exception):
544
+ # If we can't fetch the service or parse the action, fall back to prefix matching
545
+ stmt_prefix = statement_action.rstrip("*")
546
+ for pattern in patterns:
547
+ try:
548
+ if re.match(pattern, stmt_prefix):
549
+ return True
550
+ except re.error:
551
+ continue
552
+
553
+ # Regex pattern match (from action_patterns config)
554
+ for pattern in patterns:
555
+ try:
556
+ if re.match(pattern, statement_action):
557
+ return True
558
+ except re.error:
559
+ continue
560
+
561
+ return False
562
+
563
+ def _validate_conditions(
564
+ self,
565
+ statement: Statement,
566
+ statement_idx: int,
567
+ required_conditions_config: Any,
568
+ matching_actions: list[str],
569
+ config: CheckConfig,
570
+ requirement: dict[str, Any] | None = None,
571
+ ) -> list[ValidationIssue]:
572
+ """
573
+ Validate that required conditions are present.
574
+ Supports: simple list, all_of, any_of formats.
575
+ Can use per-requirement severity override from requirement['severity'].
576
+ """
577
+ issues: list[ValidationIssue] = []
578
+
579
+ # Handle simple list format (backward compatibility)
580
+ if isinstance(required_conditions_config, list):
581
+ for condition_requirement in required_conditions_config:
582
+ if not self._has_condition_requirement(statement, condition_requirement):
583
+ issues.append(
584
+ self._create_issue(
585
+ statement,
586
+ statement_idx,
587
+ condition_requirement,
588
+ matching_actions,
589
+ config,
590
+ requirement=requirement,
591
+ )
592
+ )
593
+ return issues
594
+
595
+ # Handle all_of/any_of/none_of format
596
+ if isinstance(required_conditions_config, dict):
597
+ all_of = required_conditions_config.get("all_of", [])
598
+ any_of = required_conditions_config.get("any_of", [])
599
+ none_of = required_conditions_config.get("none_of", [])
600
+
601
+ # Validate all_of: ALL conditions must be present
602
+ if all_of:
603
+ for condition_requirement in all_of:
604
+ if not self._has_condition_requirement(statement, condition_requirement):
605
+ issues.append(
606
+ self._create_issue(
607
+ statement,
608
+ statement_idx,
609
+ condition_requirement,
610
+ matching_actions,
611
+ config,
612
+ requirement_type="all_of",
613
+ requirement=requirement,
614
+ )
615
+ )
616
+
617
+ # Validate any_of: At least ONE condition must be present
618
+ if any_of:
619
+ any_present = any(
620
+ self._has_condition_requirement(statement, cond_req) for cond_req in any_of
621
+ )
622
+
623
+ if not any_present:
624
+ # Create a combined error for any_of
625
+ condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
626
+ issues.append(
627
+ ValidationIssue(
628
+ severity=self.get_severity(config),
629
+ statement_sid=statement.sid,
630
+ statement_index=statement_idx,
631
+ issue_type="missing_required_condition_any_of",
632
+ message=(
633
+ f"Actions {matching_actions} require at least ONE of these conditions: "
634
+ f"{', '.join(condition_keys)}"
635
+ ),
636
+ action=", ".join(matching_actions),
637
+ suggestion=self._build_any_of_suggestion(any_of),
638
+ line_number=statement.line_number,
639
+ )
640
+ )
641
+
642
+ # Validate none_of: NONE of these conditions should be present
643
+ if none_of:
644
+ for condition_requirement in none_of:
645
+ if self._has_condition_requirement(statement, condition_requirement):
646
+ issues.append(
647
+ self._create_none_of_issue(
648
+ statement,
649
+ statement_idx,
650
+ condition_requirement,
651
+ matching_actions,
652
+ config,
653
+ )
654
+ )
655
+
656
+ return issues
657
+
658
+ def _has_condition_requirement(
659
+ self, statement: Statement, condition_requirement: dict[str, Any]
660
+ ) -> bool:
661
+ """Check if statement has the required condition."""
662
+ condition_key = condition_requirement.get("condition_key")
663
+ if not condition_key:
664
+ return True # No condition key specified, skip
665
+
666
+ operator = condition_requirement.get("operator")
667
+ expected_value = condition_requirement.get("expected_value")
668
+
669
+ return self._has_condition(statement, condition_key, operator, expected_value)
670
+
671
+ def _has_condition(
672
+ self,
673
+ statement: Statement,
674
+ condition_key: str,
675
+ operator: str | None = None,
676
+ expected_value: Any = None,
677
+ ) -> bool:
678
+ """
679
+ Check if statement has the specified condition key.
680
+
681
+ Args:
682
+ statement: The IAM policy statement
683
+ condition_key: The condition key to look for
684
+ operator: Optional specific operator (e.g., "StringEquals")
685
+ expected_value: Optional expected value for the condition
686
+
687
+ Returns:
688
+ True if condition is present (and matches expected value if specified)
689
+ """
690
+ if not statement.condition:
691
+ return False
692
+
693
+ # If operator specified, only check that operator
694
+ operators_to_check = [operator] if operator else list(statement.condition.keys())
695
+
696
+ # Look through specified condition operators
697
+ for op in operators_to_check:
698
+ if op not in statement.condition:
699
+ continue
700
+
701
+ conditions = statement.condition[op]
702
+ if isinstance(conditions, dict):
703
+ if condition_key in conditions:
704
+ # If no expected value specified, just presence is enough
705
+ if expected_value is None:
706
+ return True
707
+
708
+ # Check if the value matches
709
+ actual_value = conditions[condition_key]
710
+
711
+ # Handle boolean values
712
+ if isinstance(expected_value, bool):
713
+ if isinstance(actual_value, bool):
714
+ return actual_value == expected_value
715
+ if isinstance(actual_value, str):
716
+ return actual_value.lower() == str(expected_value).lower()
717
+
718
+ # Handle exact matches
719
+ if actual_value == expected_value:
720
+ return True
721
+
722
+ # Handle list values (actual can be string or list)
723
+ if isinstance(expected_value, list):
724
+ if isinstance(actual_value, list):
725
+ return set(expected_value) == set(actual_value)
726
+ if actual_value in expected_value:
727
+ return True
728
+
729
+ # Handle string matches for variable references like ${aws:PrincipalTag/owner}
730
+ if str(actual_value) == str(expected_value):
731
+ return True
732
+
733
+ return False
734
+
735
+ def _create_issue(
736
+ self,
737
+ statement: Statement,
738
+ statement_idx: int,
739
+ condition_requirement: dict[str, Any],
740
+ matching_actions: list[str],
741
+ config: CheckConfig,
742
+ requirement_type: str = "required",
743
+ requirement: dict[str, Any] | None = None,
744
+ ) -> ValidationIssue:
745
+ """Create a validation issue for a missing condition.
746
+
747
+ Severity precedence:
748
+ 1. Individual condition requirement's severity (condition_requirement['severity'])
749
+ 2. Parent requirement's severity (requirement['severity'])
750
+ 3. Global check severity (config.severity)
751
+ """
752
+ condition_key = condition_requirement.get("condition_key", "unknown")
753
+ description = condition_requirement.get("description", "")
754
+ expected_value = condition_requirement.get("expected_value")
755
+ example = condition_requirement.get("example", "")
756
+ operator = condition_requirement.get("operator", "StringEquals")
757
+
758
+ message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
759
+
760
+ # Determine severity with precedence: condition > requirement > global
761
+ severity = (
762
+ condition_requirement.get("severity") # Condition-level override
763
+ or (requirement.get("severity") if requirement else None) # Requirement-level override
764
+ or self.get_severity(config) # Global check severity
765
+ )
766
+
767
+ return ValidationIssue(
768
+ severity=severity,
769
+ statement_sid=statement.sid,
770
+ statement_index=statement_idx,
771
+ issue_type="missing_required_condition",
772
+ message=f"{message_prefix} Action(s) {matching_actions} require condition '{condition_key}'",
773
+ action=", ".join(matching_actions),
774
+ condition_key=condition_key,
775
+ suggestion=self._build_suggestion(
776
+ condition_key, description, example, expected_value, operator
777
+ ),
778
+ line_number=statement.line_number,
779
+ )
780
+
781
+ def _build_suggestion(
782
+ self,
783
+ condition_key: str,
784
+ description: str,
785
+ example: str,
786
+ expected_value: Any = None,
787
+ operator: str = "StringEquals",
788
+ ) -> str:
789
+ """Build a helpful suggestion for adding the missing condition."""
790
+ parts = []
791
+
792
+ if description:
793
+ parts.append(description)
794
+
795
+ # Build example based on condition key type
796
+ if example:
797
+ parts.append(f"Example:\n```json\n{example}\n```")
798
+ else:
799
+ # Auto-generate example
800
+ example_lines = ['Add to "Condition" block:', f' "{operator}": {{']
801
+
802
+ if isinstance(expected_value, list):
803
+ value_str = (
804
+ "["
805
+ + ", ".join(
806
+ [
807
+ f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
808
+ for v in expected_value
809
+ ]
810
+ )
811
+ + "]"
812
+ )
813
+ elif expected_value is not None:
814
+ # Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
815
+ if str(expected_value).startswith("${"):
816
+ value_str = f'"{expected_value}"'
817
+ elif isinstance(expected_value, bool):
818
+ value_str = str(expected_value).lower()
819
+ else:
820
+ value_str = f'"{expected_value}"'
821
+ else:
822
+ value_str = '"<value>"'
823
+
824
+ example_lines.append(f' "{condition_key}": {value_str}')
825
+ example_lines.append(" }")
826
+
827
+ parts.append("\n".join(example_lines))
828
+
829
+ return ". ".join(parts) if parts else f"Add condition: {condition_key}"
830
+
831
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
832
+ """Build suggestion for any_of conditions."""
833
+ suggestions = []
834
+ suggestions.append("Add at least ONE of these conditions:")
835
+
836
+ for i, cond in enumerate(any_of_conditions, 1):
837
+ condition_key = cond.get("condition_key", "unknown")
838
+ description = cond.get("description", "")
839
+ expected_value = cond.get("expected_value")
840
+
841
+ option = f"\nOption {i}: {condition_key}"
842
+ if description:
843
+ option += f" - {description}"
844
+ if expected_value is not None:
845
+ option += f" (value: {expected_value})"
846
+
847
+ suggestions.append(option)
848
+
849
+ return "".join(suggestions)
850
+
851
+ def _create_none_of_issue(
852
+ self,
853
+ statement: Statement,
854
+ statement_idx: int,
855
+ condition_requirement: dict[str, Any],
856
+ matching_actions: list[str],
857
+ config: CheckConfig,
858
+ ) -> ValidationIssue:
859
+ """Create a validation issue for a forbidden condition that is present."""
860
+ condition_key = condition_requirement.get("condition_key", "unknown")
861
+ description = condition_requirement.get("description", "")
862
+ expected_value = condition_requirement.get("expected_value")
863
+
864
+ message = (
865
+ f"FORBIDDEN: Action(s) {matching_actions} must NOT have condition '{condition_key}'"
866
+ )
867
+ if expected_value is not None:
868
+ message += f" with value '{expected_value}'"
869
+
870
+ suggestion = f"Remove the '{condition_key}' condition from the statement"
871
+ if description:
872
+ suggestion += f". {description}"
873
+
874
+ return ValidationIssue(
875
+ severity=self.get_severity(config),
876
+ statement_sid=statement.sid,
877
+ statement_index=statement_idx,
878
+ issue_type="forbidden_condition_present",
879
+ message=message,
880
+ action=", ".join(matching_actions),
881
+ condition_key=condition_key,
882
+ suggestion=suggestion,
883
+ line_number=statement.line_number,
884
+ )