iam-policy-validator 1.4.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 (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.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 +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,727 @@
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
+
9
+ Common use cases:
10
+ - iam:PassRole must have iam:PassedToService condition
11
+ - Sensitive actions must have MFA conditions
12
+ - Actions must have source IP restrictions
13
+ - Resources must have required tags
14
+ - Combine multiple conditions (MFA + IP + Tags)
15
+
16
+ Configuration in iam-validator.yaml:
17
+
18
+ checks:
19
+ action_condition_enforcement:
20
+ enabled: true
21
+ severity: error
22
+ description: "Enforce specific conditions for specific actions"
23
+
24
+ action_condition_requirements:
25
+ # BASIC: Simple action with required condition
26
+ - actions:
27
+ - "iam:PassRole"
28
+ required_conditions:
29
+ - condition_key: "iam:PassedToService"
30
+ description: "Specify which AWS services can use the passed role"
31
+
32
+ # MFA + IP restrictions
33
+ - actions:
34
+ - "iam:DeleteUser"
35
+ required_conditions:
36
+ all_of:
37
+ - condition_key: "aws:MultiFactorAuthPresent"
38
+ expected_value: true
39
+ - condition_key: "aws:SourceIp"
40
+
41
+ # EC2 with TAGS + MFA + Region
42
+ - actions:
43
+ - "ec2:RunInstances"
44
+ required_conditions:
45
+ all_of:
46
+ - condition_key: "aws:MultiFactorAuthPresent"
47
+ expected_value: true
48
+ - condition_key: "aws:RequestTag/Environment"
49
+ operator: "StringEquals"
50
+ expected_value: ["Production", "Staging", "Development"]
51
+ - condition_key: "aws:RequestTag/Owner"
52
+ - condition_key: "aws:RequestedRegion"
53
+ expected_value: ["us-east-1", "us-west-2"]
54
+
55
+ # Principal-to-resource tag matching
56
+ - actions:
57
+ - "ec2:RunInstances"
58
+ required_conditions:
59
+ - condition_key: "aws:ResourceTag/owner"
60
+ operator: "StringEquals"
61
+ expected_value: "${aws:PrincipalTag/owner}"
62
+ description: "Resource owner must match principal's owner tag"
63
+
64
+ # Complex: all_of + any_of for actions and conditions
65
+ - actions:
66
+ any_of:
67
+ - "cloudformation:CreateStack"
68
+ - "cloudformation:UpdateStack"
69
+ required_conditions:
70
+ all_of:
71
+ - condition_key: "aws:MultiFactorAuthPresent"
72
+ expected_value: true
73
+ - condition_key: "aws:RequestTag/Environment"
74
+ any_of:
75
+ - condition_key: "aws:SourceIp"
76
+ - condition_key: "aws:SourceVpce"
77
+
78
+ # none_of for conditions: Ensure certain conditions are NOT present
79
+ - actions:
80
+ - "s3:GetObject"
81
+ required_conditions:
82
+ none_of:
83
+ - condition_key: "aws:SecureTransport"
84
+ expected_value: false
85
+ description: "Ensure insecure transport is never allowed"
86
+
87
+ # none_of for actions: Flag if forbidden actions are present
88
+ - actions:
89
+ none_of:
90
+ - "iam:*"
91
+ - "s3:DeleteBucket"
92
+ description: "These dangerous actions should never be used"
93
+ """
94
+
95
+ import re
96
+ from typing import Any
97
+
98
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
99
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
100
+ from iam_validator.core.models import Statement, ValidationIssue
101
+
102
+
103
+ class ActionConditionEnforcementCheck(PolicyCheck):
104
+ """Enforces specific condition requirements for specific actions with all_of/any_of support."""
105
+
106
+ @property
107
+ def check_id(self) -> str:
108
+ return "action_condition_enforcement"
109
+
110
+ @property
111
+ def description(self) -> str:
112
+ return "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
113
+
114
+ @property
115
+ def default_severity(self) -> str:
116
+ return "error"
117
+
118
+ async def execute(
119
+ self,
120
+ statement: Statement,
121
+ statement_idx: int,
122
+ fetcher: AWSServiceFetcher,
123
+ config: CheckConfig,
124
+ ) -> list[ValidationIssue]:
125
+ """Execute condition enforcement check."""
126
+ issues = []
127
+
128
+ # Only check Allow statements
129
+ if statement.effect != "Allow":
130
+ return issues
131
+
132
+ # Get action condition requirements from config
133
+ action_condition_requirements = config.config.get("action_condition_requirements", [])
134
+ if not action_condition_requirements:
135
+ return issues
136
+
137
+ statement_actions = statement.get_actions()
138
+
139
+ # Check each requirement rule
140
+ for requirement in action_condition_requirements:
141
+ # Check if this requirement applies to the statement's actions
142
+ actions_match, matching_actions = await self._check_action_match(
143
+ statement_actions, requirement, fetcher
144
+ )
145
+
146
+ if not actions_match:
147
+ continue
148
+
149
+ # Check if this is a none_of action rule (forbidden actions)
150
+ actions_config = requirement.get("actions", [])
151
+ if isinstance(actions_config, dict) and "none_of" in actions_config:
152
+ # This is a forbidden action rule - flag it
153
+ description = requirement.get("description", "These actions should not be used")
154
+ # Use per-requirement severity if specified, else use global
155
+ severity = requirement.get("severity", self.get_severity(config))
156
+ issues.append(
157
+ ValidationIssue(
158
+ severity=severity,
159
+ statement_sid=statement.sid,
160
+ statement_index=statement_idx,
161
+ issue_type="forbidden_action_present",
162
+ message=f"FORBIDDEN: Actions {matching_actions} should not be used. {description}",
163
+ action=", ".join(matching_actions),
164
+ suggestion=f"Remove these forbidden actions from the statement: {', '.join(matching_actions)}. {description}",
165
+ line_number=statement.line_number,
166
+ )
167
+ )
168
+ continue
169
+
170
+ # Actions match - now validate required conditions
171
+ required_conditions_config = requirement.get("required_conditions", [])
172
+ if not required_conditions_config:
173
+ continue
174
+
175
+ # Check if conditions are in all_of/any_of/none_of format or simple list
176
+ condition_issues = self._validate_conditions(
177
+ statement,
178
+ statement_idx,
179
+ required_conditions_config,
180
+ matching_actions,
181
+ config,
182
+ requirement, # Pass the full requirement for severity override
183
+ )
184
+
185
+ issues.extend(condition_issues)
186
+
187
+ return issues
188
+
189
+ async def _check_action_match(
190
+ self, statement_actions: list[str], requirement: dict[str, Any], fetcher: AWSServiceFetcher
191
+ ) -> tuple[bool, list[str]]:
192
+ """
193
+ Check if statement actions match the requirement.
194
+ Supports: simple list, all_of, any_of, none_of, and action_patterns.
195
+
196
+ Returns:
197
+ (matches, list_of_matching_actions)
198
+ """
199
+ actions_config = requirement.get("actions", [])
200
+ action_patterns = requirement.get("action_patterns", [])
201
+
202
+ matching_actions: list[str] = []
203
+
204
+ # Handle simple list format (backward compatibility)
205
+ if isinstance(actions_config, list) and actions_config:
206
+ # Simple list - check if any action matches
207
+ for stmt_action in statement_actions:
208
+ if stmt_action == "*":
209
+ continue
210
+
211
+ # Check if this statement action matches any of the required actions or patterns
212
+ # Use _action_matches which handles wildcards in both statement and config
213
+ matched = False
214
+
215
+ # Check against configured actions
216
+ for required_action in actions_config:
217
+ if await self._action_matches(
218
+ stmt_action, required_action, action_patterns, fetcher
219
+ ):
220
+ matched = True
221
+ break
222
+
223
+ # If not matched by actions, check if wildcard overlaps with patterns
224
+ if not matched and "*" in stmt_action:
225
+ # For wildcards, also check pattern overlap directly
226
+ matched = await self._action_matches(stmt_action, "", action_patterns, fetcher)
227
+
228
+ if matched and stmt_action not in matching_actions:
229
+ matching_actions.append(stmt_action)
230
+
231
+ return len(matching_actions) > 0, matching_actions
232
+
233
+ # Handle all_of/any_of/none_of format
234
+ if isinstance(actions_config, dict):
235
+ all_of = actions_config.get("all_of", [])
236
+ any_of = actions_config.get("any_of", [])
237
+ none_of = actions_config.get("none_of", [])
238
+
239
+ # Check all_of: ALL specified actions must be in statement
240
+ if all_of:
241
+ all_present = True
242
+ for req_action in all_of:
243
+ found = False
244
+ for stmt_action in statement_actions:
245
+ if await self._action_matches(
246
+ stmt_action, req_action, action_patterns, fetcher
247
+ ):
248
+ found = True
249
+ break
250
+ if not found:
251
+ all_present = False
252
+ break
253
+
254
+ if not all_present:
255
+ return False, []
256
+
257
+ # Collect matching actions
258
+ for stmt_action in statement_actions:
259
+ for req_action in all_of:
260
+ if await self._action_matches(
261
+ stmt_action, req_action, action_patterns, fetcher
262
+ ):
263
+ if stmt_action not in matching_actions:
264
+ matching_actions.append(stmt_action)
265
+
266
+ # Check any_of: At least ONE specified action must be in statement
267
+ if any_of:
268
+ any_present = False
269
+ for stmt_action in statement_actions:
270
+ for req_action in any_of:
271
+ if await self._action_matches(
272
+ stmt_action, req_action, action_patterns, fetcher
273
+ ):
274
+ any_present = True
275
+ if stmt_action not in matching_actions:
276
+ matching_actions.append(stmt_action)
277
+
278
+ if not any_present:
279
+ return False, []
280
+
281
+ # Check none_of: NONE of the specified actions should be in statement
282
+ if none_of:
283
+ forbidden_actions = []
284
+ for stmt_action in statement_actions:
285
+ for forbidden_action in none_of:
286
+ if await self._action_matches(
287
+ stmt_action, forbidden_action, action_patterns, fetcher
288
+ ):
289
+ forbidden_actions.append(stmt_action)
290
+
291
+ # If forbidden actions are found, this is a match for flagging
292
+ if forbidden_actions:
293
+ return True, forbidden_actions
294
+
295
+ return len(matching_actions) > 0, matching_actions
296
+
297
+ return False, []
298
+
299
+ async def _action_matches(
300
+ self,
301
+ statement_action: str,
302
+ required_action: str,
303
+ patterns: list[str],
304
+ fetcher: AWSServiceFetcher,
305
+ ) -> bool:
306
+ """
307
+ Check if a statement action matches a required action or pattern.
308
+ Supports:
309
+ - Exact matches: "s3:GetObject"
310
+ - AWS wildcards in both statement and required actions: "s3:*", "s3:Get*", "iam:Creat*"
311
+ - Regex patterns: "^s3:Get.*", "^iam:Delete.*"
312
+
313
+ This method handles bidirectional wildcard matching using real AWS actions from the fetcher:
314
+ - statement_action="iam:Create*" matches required_action="iam:CreateUser"
315
+ - statement_action="iam:C*" matches pattern="^iam:Create" (by checking actual AWS actions)
316
+ """
317
+ if statement_action == "*":
318
+ return False
319
+
320
+ # Exact match
321
+ if statement_action == required_action:
322
+ return True
323
+
324
+ # AWS wildcard match in required_action (e.g., "s3:*", "s3:Get*")
325
+ if "*" in required_action:
326
+ # Convert AWS wildcard to regex
327
+ wildcard_pattern = required_action.replace("*", ".*").replace("?", ".")
328
+ try:
329
+ if re.match(f"^{wildcard_pattern}$", statement_action):
330
+ return True
331
+ except re.error:
332
+ pass
333
+
334
+ # AWS wildcard match in statement_action (e.g., "iam:Creat*" in policy)
335
+ # Check if this wildcard would grant access to actions matching our patterns
336
+ if "*" in statement_action:
337
+ # Convert statement wildcard to regex pattern
338
+ stmt_wildcard_pattern = statement_action.replace("*", ".*").replace("?", ".")
339
+
340
+ # Check if statement wildcard overlaps with required action
341
+ if "*" not in required_action:
342
+ # Required action is specific (e.g., "iam:CreateUser")
343
+ # Check if statement wildcard would grant it
344
+ try:
345
+ if re.match(f"^{stmt_wildcard_pattern}$", required_action):
346
+ return True
347
+ except re.error:
348
+ pass
349
+
350
+ # Check if statement wildcard overlaps with any of our action patterns
351
+ # Strategy: Use real AWS actions from the fetcher instead of hardcoded guesses
352
+ # For example: "iam:C*" should match pattern "^iam:Create" because:
353
+ # - "iam:C*" grants iam:CreateUser, iam:CreateRole, etc. (from AWS)
354
+ # - "^iam:Create" pattern is meant to catch iam:CreateUser, iam:CreateRole, etc.
355
+ # - Therefore they overlap
356
+ if patterns:
357
+ try:
358
+ # Parse the service from the wildcard action
359
+ service_prefix, _ = fetcher.parse_action(statement_action)
360
+
361
+ # Fetch the real list of actions for this service
362
+ service_detail = await fetcher.fetch_service_by_name(service_prefix)
363
+ available_actions = list(service_detail.actions.keys())
364
+
365
+ # Find which actual AWS actions the wildcard would grant
366
+ _, granted_actions = fetcher._match_wildcard_action(
367
+ statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
368
+ available_actions,
369
+ )
370
+
371
+ # Check if any of the granted actions match our patterns
372
+ for granted_action in granted_actions:
373
+ full_granted_action = f"{service_prefix}:{granted_action}"
374
+ for pattern in patterns:
375
+ try:
376
+ if re.match(pattern, full_granted_action):
377
+ return True
378
+ except re.error:
379
+ continue
380
+
381
+ except (ValueError, Exception):
382
+ # If we can't fetch the service or parse the action, fall back to prefix matching
383
+ stmt_prefix = statement_action.rstrip("*")
384
+ for pattern in patterns:
385
+ try:
386
+ if re.match(pattern, stmt_prefix):
387
+ return True
388
+ except re.error:
389
+ continue
390
+
391
+ # Regex pattern match (from action_patterns config)
392
+ for pattern in patterns:
393
+ try:
394
+ if re.match(pattern, statement_action):
395
+ return True
396
+ except re.error:
397
+ continue
398
+
399
+ return False
400
+
401
+ def _validate_conditions(
402
+ self,
403
+ statement: Statement,
404
+ statement_idx: int,
405
+ required_conditions_config: Any,
406
+ matching_actions: list[str],
407
+ config: CheckConfig,
408
+ requirement: dict[str, Any] | None = None,
409
+ ) -> list[ValidationIssue]:
410
+ """
411
+ Validate that required conditions are present.
412
+ Supports: simple list, all_of, any_of formats.
413
+ Can use per-requirement severity override from requirement['severity'].
414
+ """
415
+ issues: list[ValidationIssue] = []
416
+
417
+ # Handle simple list format (backward compatibility)
418
+ if isinstance(required_conditions_config, list):
419
+ for condition_requirement in required_conditions_config:
420
+ if not self._has_condition_requirement(statement, condition_requirement):
421
+ issues.append(
422
+ self._create_issue(
423
+ statement,
424
+ statement_idx,
425
+ condition_requirement,
426
+ matching_actions,
427
+ config,
428
+ requirement=requirement,
429
+ )
430
+ )
431
+ return issues
432
+
433
+ # Handle all_of/any_of/none_of format
434
+ if isinstance(required_conditions_config, dict):
435
+ all_of = required_conditions_config.get("all_of", [])
436
+ any_of = required_conditions_config.get("any_of", [])
437
+ none_of = required_conditions_config.get("none_of", [])
438
+
439
+ # Validate all_of: ALL conditions must be present
440
+ if all_of:
441
+ for condition_requirement in all_of:
442
+ if not self._has_condition_requirement(statement, condition_requirement):
443
+ issues.append(
444
+ self._create_issue(
445
+ statement,
446
+ statement_idx,
447
+ condition_requirement,
448
+ matching_actions,
449
+ config,
450
+ requirement_type="all_of",
451
+ requirement=requirement,
452
+ )
453
+ )
454
+
455
+ # Validate any_of: At least ONE condition must be present
456
+ if any_of:
457
+ any_present = any(
458
+ self._has_condition_requirement(statement, cond_req) for cond_req in any_of
459
+ )
460
+
461
+ if not any_present:
462
+ # Create a combined error for any_of
463
+ condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
464
+ issues.append(
465
+ ValidationIssue(
466
+ severity=self.get_severity(config),
467
+ statement_sid=statement.sid,
468
+ statement_index=statement_idx,
469
+ issue_type="missing_required_condition_any_of",
470
+ message=(
471
+ f"Actions {matching_actions} require at least ONE of these conditions: "
472
+ f"{', '.join(condition_keys)}"
473
+ ),
474
+ action=", ".join(matching_actions),
475
+ suggestion=self._build_any_of_suggestion(any_of),
476
+ line_number=statement.line_number,
477
+ )
478
+ )
479
+
480
+ # Validate none_of: NONE of these conditions should be present
481
+ if none_of:
482
+ for condition_requirement in none_of:
483
+ if self._has_condition_requirement(statement, condition_requirement):
484
+ issues.append(
485
+ self._create_none_of_issue(
486
+ statement,
487
+ statement_idx,
488
+ condition_requirement,
489
+ matching_actions,
490
+ config,
491
+ )
492
+ )
493
+
494
+ return issues
495
+
496
+ def _has_condition_requirement(
497
+ self, statement: Statement, condition_requirement: dict[str, Any]
498
+ ) -> bool:
499
+ """Check if statement has the required condition."""
500
+ condition_key = condition_requirement.get("condition_key")
501
+ if not condition_key:
502
+ return True # No condition key specified, skip
503
+
504
+ operator = condition_requirement.get("operator")
505
+ expected_value = condition_requirement.get("expected_value")
506
+
507
+ return self._has_condition(statement, condition_key, operator, expected_value)
508
+
509
+ def _has_condition(
510
+ self,
511
+ statement: Statement,
512
+ condition_key: str,
513
+ operator: str | None = None,
514
+ expected_value: Any = None,
515
+ ) -> bool:
516
+ """
517
+ Check if statement has the specified condition key.
518
+
519
+ Args:
520
+ statement: The IAM policy statement
521
+ condition_key: The condition key to look for
522
+ operator: Optional specific operator (e.g., "StringEquals")
523
+ expected_value: Optional expected value for the condition
524
+
525
+ Returns:
526
+ True if condition is present (and matches expected value if specified)
527
+ """
528
+ if not statement.condition:
529
+ return False
530
+
531
+ # If operator specified, only check that operator
532
+ operators_to_check = [operator] if operator else list(statement.condition.keys())
533
+
534
+ # Look through specified condition operators
535
+ for op in operators_to_check:
536
+ if op not in statement.condition:
537
+ continue
538
+
539
+ conditions = statement.condition[op]
540
+ if isinstance(conditions, dict):
541
+ if condition_key in conditions:
542
+ # If no expected value specified, just presence is enough
543
+ if expected_value is None:
544
+ return True
545
+
546
+ # Check if the value matches
547
+ actual_value = conditions[condition_key]
548
+
549
+ # Handle boolean values
550
+ if isinstance(expected_value, bool):
551
+ if isinstance(actual_value, bool):
552
+ return actual_value == expected_value
553
+ if isinstance(actual_value, str):
554
+ return actual_value.lower() == str(expected_value).lower()
555
+
556
+ # Handle exact matches
557
+ if actual_value == expected_value:
558
+ return True
559
+
560
+ # Handle list values (actual can be string or list)
561
+ if isinstance(expected_value, list):
562
+ if isinstance(actual_value, list):
563
+ return set(expected_value) == set(actual_value)
564
+ if actual_value in expected_value:
565
+ return True
566
+
567
+ # Handle string matches for variable references like ${aws:PrincipalTag/owner}
568
+ if str(actual_value) == str(expected_value):
569
+ return True
570
+
571
+ return False
572
+
573
+ def _create_issue(
574
+ self,
575
+ statement: Statement,
576
+ statement_idx: int,
577
+ condition_requirement: dict[str, Any],
578
+ matching_actions: list[str],
579
+ config: CheckConfig,
580
+ requirement_type: str = "required",
581
+ requirement: dict[str, Any] | None = None,
582
+ ) -> ValidationIssue:
583
+ """Create a validation issue for a missing condition.
584
+
585
+ Severity precedence:
586
+ 1. Individual condition requirement's severity (condition_requirement['severity'])
587
+ 2. Parent requirement's severity (requirement['severity'])
588
+ 3. Global check severity (config.severity)
589
+ """
590
+ condition_key = condition_requirement.get("condition_key", "unknown")
591
+ description = condition_requirement.get("description", "")
592
+ expected_value = condition_requirement.get("expected_value")
593
+ example = condition_requirement.get("example", "")
594
+ operator = condition_requirement.get("operator", "StringEquals")
595
+
596
+ message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
597
+
598
+ # Determine severity with precedence: condition > requirement > global
599
+ severity = (
600
+ condition_requirement.get("severity") # Condition-level override
601
+ or (requirement.get("severity") if requirement else None) # Requirement-level override
602
+ or self.get_severity(config) # Global check severity
603
+ )
604
+
605
+ return ValidationIssue(
606
+ severity=severity,
607
+ statement_sid=statement.sid,
608
+ statement_index=statement_idx,
609
+ issue_type="missing_required_condition",
610
+ message=(
611
+ f"{message_prefix} Action(s) {matching_actions} require condition '{condition_key}'. "
612
+ f"{description}"
613
+ ),
614
+ action=", ".join(matching_actions),
615
+ condition_key=condition_key,
616
+ suggestion=self._build_suggestion(
617
+ condition_key, description, example, expected_value, operator
618
+ ),
619
+ line_number=statement.line_number,
620
+ )
621
+
622
+ def _build_suggestion(
623
+ self,
624
+ condition_key: str,
625
+ description: str,
626
+ example: str,
627
+ expected_value: Any = None,
628
+ operator: str = "StringEquals",
629
+ ) -> str:
630
+ """Build a helpful suggestion for adding the missing condition."""
631
+ parts = []
632
+
633
+ if description:
634
+ parts.append(description)
635
+
636
+ # Build example based on condition key type
637
+ if example:
638
+ parts.append(f"Example:\n{example}")
639
+ else:
640
+ # Auto-generate example
641
+ example_lines = ['Add to "Condition" block:', f' "{operator}": {{']
642
+
643
+ if isinstance(expected_value, list):
644
+ value_str = (
645
+ "["
646
+ + ", ".join(
647
+ [
648
+ f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
649
+ for v in expected_value
650
+ ]
651
+ )
652
+ + "]"
653
+ )
654
+ elif expected_value is not None:
655
+ # Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
656
+ if str(expected_value).startswith("${"):
657
+ value_str = f'"{expected_value}"'
658
+ elif isinstance(expected_value, bool):
659
+ value_str = str(expected_value).lower()
660
+ else:
661
+ value_str = f'"{expected_value}"'
662
+ else:
663
+ value_str = '"<value>"'
664
+
665
+ example_lines.append(f' "{condition_key}": {value_str}')
666
+ example_lines.append(" }")
667
+
668
+ parts.append("\n".join(example_lines))
669
+
670
+ return ". ".join(parts) if parts else f"Add condition: {condition_key}"
671
+
672
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
673
+ """Build suggestion for any_of conditions."""
674
+ suggestions = []
675
+ suggestions.append("Add at least ONE of these conditions:")
676
+
677
+ for i, cond in enumerate(any_of_conditions, 1):
678
+ condition_key = cond.get("condition_key", "unknown")
679
+ description = cond.get("description", "")
680
+ expected_value = cond.get("expected_value")
681
+
682
+ option = f"\nOption {i}: {condition_key}"
683
+ if description:
684
+ option += f" - {description}"
685
+ if expected_value is not None:
686
+ option += f" (value: {expected_value})"
687
+
688
+ suggestions.append(option)
689
+
690
+ return "".join(suggestions)
691
+
692
+ def _create_none_of_issue(
693
+ self,
694
+ statement: Statement,
695
+ statement_idx: int,
696
+ condition_requirement: dict[str, Any],
697
+ matching_actions: list[str],
698
+ config: CheckConfig,
699
+ ) -> ValidationIssue:
700
+ """Create a validation issue for a forbidden condition that is present."""
701
+ condition_key = condition_requirement.get("condition_key", "unknown")
702
+ description = condition_requirement.get("description", "")
703
+ expected_value = condition_requirement.get("expected_value")
704
+
705
+ message = (
706
+ f"FORBIDDEN: Action(s) {matching_actions} must NOT have condition '{condition_key}'"
707
+ )
708
+ if expected_value is not None:
709
+ message += f" with value '{expected_value}'"
710
+ if description:
711
+ message += f". {description}"
712
+
713
+ suggestion = f"Remove the '{condition_key}' condition from the statement"
714
+ if description:
715
+ suggestion += f". {description}"
716
+
717
+ return ValidationIssue(
718
+ severity=self.get_severity(config),
719
+ statement_sid=statement.sid,
720
+ statement_index=statement_idx,
721
+ issue_type="forbidden_condition_present",
722
+ message=message,
723
+ action=", ".join(matching_actions),
724
+ condition_key=condition_key,
725
+ suggestion=suggestion,
726
+ line_number=statement.line_number,
727
+ )