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,776 @@
1
+ """Principal Validation Check.
2
+
3
+ Validates Principal elements in resource-based policies for security best practices.
4
+ This check enforces:
5
+ - Blocked principals (e.g., public access via "*")
6
+ - Allowed principals whitelist (optional)
7
+ - Required conditions for specific principals (simple format)
8
+ - Rich condition requirements for principals (advanced format with all_of/any_of)
9
+ - Service principal validation
10
+
11
+ Only runs for RESOURCE_POLICY type policies.
12
+
13
+ Configuration supports TWO formats:
14
+
15
+ 1. Simple format (backward compatible):
16
+ require_conditions_for:
17
+ "*": ["aws:SourceArn", "aws:SourceAccount"]
18
+ "arn:aws:iam::*:root": ["aws:PrincipalOrgID"]
19
+
20
+ 2. Advanced format with rich condition requirements:
21
+ principal_condition_requirements:
22
+ - principals:
23
+ - "*"
24
+ severity: critical
25
+ required_conditions:
26
+ all_of:
27
+ - condition_key: "aws:SourceArn"
28
+ description: "Limit by source ARN"
29
+ - condition_key: "aws:SourceAccount"
30
+
31
+ - principals:
32
+ - "arn:aws:iam::*:root"
33
+ required_conditions:
34
+ - condition_key: "aws:PrincipalOrgID"
35
+ expected_value: "o-xxxxx"
36
+ operator: "StringEquals"
37
+ """
38
+
39
+ import fnmatch
40
+ from typing import Any
41
+
42
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
43
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
44
+ from iam_validator.core.models import Statement, ValidationIssue
45
+
46
+
47
+ class PrincipalValidationCheck(PolicyCheck):
48
+ """Validates Principal elements in resource policies."""
49
+
50
+ @property
51
+ def check_id(self) -> str:
52
+ return "principal_validation"
53
+
54
+ @property
55
+ def description(self) -> str:
56
+ return "Validates Principal elements in resource policies for security best practices"
57
+
58
+ @property
59
+ def default_severity(self) -> str:
60
+ return "high"
61
+
62
+ async def execute(
63
+ self,
64
+ statement: Statement,
65
+ statement_idx: int,
66
+ fetcher: AWSServiceFetcher,
67
+ config: CheckConfig,
68
+ ) -> list[ValidationIssue]:
69
+ """Execute principal validation on a single statement.
70
+
71
+ Args:
72
+ statement: The statement to validate
73
+ statement_idx: Index of the statement in the policy
74
+ fetcher: AWS service fetcher instance
75
+ config: Configuration for this check
76
+
77
+ Returns:
78
+ List of validation issues
79
+ """
80
+ issues = []
81
+
82
+ # Skip if no principal
83
+ if statement.principal is None and statement.not_principal is None:
84
+ return issues
85
+
86
+ # Get configuration
87
+ blocked_principals = config.config.get("blocked_principals", ["*"])
88
+ allowed_principals = config.config.get("allowed_principals", [])
89
+ require_conditions_for = config.config.get("require_conditions_for", {})
90
+ principal_condition_requirements = config.config.get("principal_condition_requirements", [])
91
+ allowed_service_principals = config.config.get(
92
+ "allowed_service_principals",
93
+ [
94
+ "cloudfront.amazonaws.com",
95
+ "s3.amazonaws.com",
96
+ "sns.amazonaws.com",
97
+ "lambda.amazonaws.com",
98
+ "logs.amazonaws.com",
99
+ "events.amazonaws.com",
100
+ ],
101
+ )
102
+
103
+ # Extract principals from statement
104
+ principals = self._extract_principals(statement)
105
+
106
+ for principal in principals:
107
+ # Check if principal is blocked
108
+ if self._is_blocked_principal(
109
+ principal, blocked_principals, allowed_service_principals
110
+ ):
111
+ issues.append(
112
+ ValidationIssue(
113
+ severity=self.get_severity(config),
114
+ issue_type="blocked_principal",
115
+ message=f"Blocked principal detected: {principal}. "
116
+ f"This principal is explicitly blocked by your security policy.",
117
+ statement_index=statement_idx,
118
+ statement_sid=statement.sid,
119
+ line_number=statement.line_number,
120
+ suggestion=f"Remove the principal '{principal}' or add appropriate conditions to restrict access. "
121
+ "Consider using more specific principals instead of wildcards.",
122
+ )
123
+ )
124
+ continue
125
+
126
+ # Check if principal is in whitelist (if whitelist is configured)
127
+ if allowed_principals and not self._is_allowed_principal(
128
+ principal, allowed_principals, allowed_service_principals
129
+ ):
130
+ issues.append(
131
+ ValidationIssue(
132
+ severity=self.get_severity(config),
133
+ issue_type="unauthorized_principal",
134
+ message=f"Principal not in allowed list: {principal}. "
135
+ f"Only principals in the allowed_principals whitelist are permitted.",
136
+ statement_index=statement_idx,
137
+ statement_sid=statement.sid,
138
+ line_number=statement.line_number,
139
+ suggestion=f"Add '{principal}' to the allowed_principals list in your config, "
140
+ "or use a principal that matches an allowed pattern.",
141
+ )
142
+ )
143
+ continue
144
+
145
+ # Check simple format: require_conditions_for (backward compatible)
146
+ required_conditions = self._get_required_conditions(principal, require_conditions_for)
147
+ if required_conditions:
148
+ missing_conditions = self._check_required_conditions(statement, required_conditions)
149
+ if missing_conditions:
150
+ issues.append(
151
+ ValidationIssue(
152
+ severity=self.get_severity(config),
153
+ issue_type="missing_principal_conditions",
154
+ message=f"Principal '{principal}' requires conditions: {', '.join(missing_conditions)}. "
155
+ f"This principal must have these condition keys to restrict access.",
156
+ statement_index=statement_idx,
157
+ statement_sid=statement.sid,
158
+ line_number=statement.line_number,
159
+ suggestion=f"Add conditions to restrict access:\n"
160
+ f"Example:\n"
161
+ f'"Condition": {{\n'
162
+ f' "StringEquals": {{\n'
163
+ f' "{missing_conditions[0]}": "value"\n'
164
+ f" }}\n"
165
+ f"}}",
166
+ )
167
+ )
168
+
169
+ # Check advanced format: principal_condition_requirements
170
+ if principal_condition_requirements:
171
+ condition_issues = self._validate_principal_condition_requirements(
172
+ statement, statement_idx, principals, principal_condition_requirements, config
173
+ )
174
+ issues.extend(condition_issues)
175
+
176
+ return issues
177
+
178
+ def _extract_principals(self, statement: Statement) -> list[str]:
179
+ """Extract all principals from a statement.
180
+
181
+ Args:
182
+ statement: The statement to extract principals from
183
+
184
+ Returns:
185
+ List of principal strings
186
+ """
187
+ principals = []
188
+
189
+ # Handle Principal field
190
+ if statement.principal:
191
+ if isinstance(statement.principal, str):
192
+ # Simple string principal like "*"
193
+ principals.append(statement.principal)
194
+ elif isinstance(statement.principal, dict):
195
+ # Dict with AWS, Service, Federated, etc.
196
+ for key, value in statement.principal.items():
197
+ if isinstance(value, str):
198
+ principals.append(value)
199
+ elif isinstance(value, list):
200
+ principals.extend(value)
201
+
202
+ # Handle NotPrincipal field (similar logic)
203
+ if statement.not_principal:
204
+ if isinstance(statement.not_principal, str):
205
+ principals.append(statement.not_principal)
206
+ elif isinstance(statement.not_principal, dict):
207
+ for key, value in statement.not_principal.items():
208
+ if isinstance(value, str):
209
+ principals.append(value)
210
+ elif isinstance(value, list):
211
+ principals.extend(value)
212
+
213
+ return principals
214
+
215
+ def _is_blocked_principal(
216
+ self, principal: str, blocked_list: list[str], service_whitelist: list[str]
217
+ ) -> bool:
218
+ """Check if a principal is blocked.
219
+
220
+ Args:
221
+ principal: The principal to check
222
+ blocked_list: List of blocked principal patterns
223
+ service_whitelist: List of allowed service principals
224
+
225
+ Returns:
226
+ True if the principal is blocked
227
+ """
228
+ # Service principals are never blocked
229
+ if principal in service_whitelist:
230
+ return False
231
+
232
+ # Check against blocked list (supports wildcards)
233
+ for blocked_pattern in blocked_list:
234
+ # Special case: "*" in blocked list should only match literal "*" (public access)
235
+ # not use it as a wildcard pattern that matches everything
236
+ if blocked_pattern == "*":
237
+ if principal == "*":
238
+ return True
239
+ elif fnmatch.fnmatch(principal, blocked_pattern):
240
+ return True
241
+
242
+ return False
243
+
244
+ def _is_allowed_principal(
245
+ self, principal: str, allowed_list: list[str], service_whitelist: list[str]
246
+ ) -> bool:
247
+ """Check if a principal is in the allowed list.
248
+
249
+ Args:
250
+ principal: The principal to check
251
+ allowed_list: List of allowed principal patterns
252
+ service_whitelist: List of allowed service principals
253
+
254
+ Returns:
255
+ True if the principal is allowed
256
+ """
257
+ # Service principals are always allowed
258
+ if principal in service_whitelist:
259
+ return True
260
+
261
+ # Check against allowed list (supports wildcards)
262
+ for allowed_pattern in allowed_list:
263
+ # Special case: "*" in allowed list should only match literal "*" (public access)
264
+ # not use it as a wildcard pattern that matches everything
265
+ if allowed_pattern == "*":
266
+ if principal == "*":
267
+ return True
268
+ elif fnmatch.fnmatch(principal, allowed_pattern):
269
+ return True
270
+
271
+ return False
272
+
273
+ def _get_required_conditions(
274
+ self, principal: str, requirements: dict[str, list[str]]
275
+ ) -> list[str]:
276
+ """Get required condition keys for a principal.
277
+
278
+ Args:
279
+ principal: The principal to check
280
+ requirements: Dict mapping principal patterns to required condition keys
281
+
282
+ Returns:
283
+ List of required condition keys
284
+ """
285
+ for pattern, condition_keys in requirements.items():
286
+ # Special case: "*" pattern should only match literal "*" (public access)
287
+ if pattern == "*":
288
+ if principal == "*":
289
+ return condition_keys
290
+ elif fnmatch.fnmatch(principal, pattern):
291
+ return condition_keys
292
+ return []
293
+
294
+ def _check_required_conditions(
295
+ self, statement: Statement, required_keys: list[str]
296
+ ) -> list[str]:
297
+ """Check if statement has required condition keys.
298
+
299
+ Args:
300
+ statement: The statement to check
301
+ required_keys: List of required condition keys
302
+
303
+ Returns:
304
+ List of missing condition keys
305
+ """
306
+ if not statement.condition:
307
+ return required_keys
308
+
309
+ # Flatten all condition keys from all condition operators
310
+ present_keys = set()
311
+ for operator_conditions in statement.condition.values():
312
+ if isinstance(operator_conditions, dict):
313
+ present_keys.update(operator_conditions.keys())
314
+
315
+ # Find missing keys
316
+ missing = [key for key in required_keys if key not in present_keys]
317
+ return missing
318
+
319
+ def _validate_principal_condition_requirements(
320
+ self,
321
+ statement: Statement,
322
+ statement_idx: int,
323
+ principals: list[str],
324
+ requirements: list[dict[str, Any]],
325
+ config: CheckConfig,
326
+ ) -> list[ValidationIssue]:
327
+ """Validate advanced principal condition requirements.
328
+
329
+ Args:
330
+ statement: The statement to validate
331
+ statement_idx: Index of the statement
332
+ principals: List of principals from the statement
333
+ requirements: List of principal condition requirements
334
+ config: Check configuration
335
+
336
+ Returns:
337
+ List of validation issues
338
+ """
339
+ issues: list[ValidationIssue] = []
340
+
341
+ # Check each requirement rule
342
+ for requirement in requirements:
343
+ # Check if any principal matches this requirement
344
+ matching_principals = self._get_matching_principals(principals, requirement)
345
+
346
+ if not matching_principals:
347
+ continue
348
+
349
+ # Get required conditions from the requirement
350
+ required_conditions_config = requirement.get("required_conditions", [])
351
+ if not required_conditions_config:
352
+ continue
353
+
354
+ # Validate conditions using the same logic as action_condition_enforcement
355
+ condition_issues = self._validate_conditions(
356
+ statement,
357
+ statement_idx,
358
+ required_conditions_config,
359
+ matching_principals,
360
+ config,
361
+ requirement,
362
+ )
363
+
364
+ issues.extend(condition_issues)
365
+
366
+ return issues
367
+
368
+ def _get_matching_principals(
369
+ self, principals: list[str], requirement: dict[str, Any]
370
+ ) -> list[str]:
371
+ """Get principals that match the requirement pattern.
372
+
373
+ Args:
374
+ principals: List of principals from the statement
375
+ requirement: Principal condition requirement config
376
+
377
+ Returns:
378
+ List of matching principals
379
+ """
380
+ principal_patterns = requirement.get("principals", [])
381
+ if not principal_patterns:
382
+ return []
383
+
384
+ matching: list[str] = []
385
+
386
+ for principal in principals:
387
+ for pattern in principal_patterns:
388
+ # Special case: "*" pattern should only match literal "*"
389
+ if pattern == "*":
390
+ if principal == "*":
391
+ matching.append(principal)
392
+ elif fnmatch.fnmatch(principal, pattern):
393
+ matching.append(principal)
394
+
395
+ return matching
396
+
397
+ def _validate_conditions(
398
+ self,
399
+ statement: Statement,
400
+ statement_idx: int,
401
+ required_conditions_config: Any,
402
+ matching_principals: list[str],
403
+ config: CheckConfig,
404
+ requirement: dict[str, Any],
405
+ ) -> list[ValidationIssue]:
406
+ """Validate that required conditions are present.
407
+
408
+ Supports: simple list, all_of, any_of, none_of formats.
409
+ Similar to action_condition_enforcement logic.
410
+
411
+ Args:
412
+ statement: The statement to validate
413
+ statement_idx: Index of the statement
414
+ required_conditions_config: Condition requirements config
415
+ matching_principals: Principals that matched
416
+ config: Check configuration
417
+ requirement: Parent requirement for severity override
418
+
419
+ Returns:
420
+ List of validation issues
421
+ """
422
+ issues: list[ValidationIssue] = []
423
+
424
+ # Handle simple list format (backward compatibility)
425
+ if isinstance(required_conditions_config, list):
426
+ for condition_requirement in required_conditions_config:
427
+ if not self._has_condition_requirement(statement, condition_requirement):
428
+ issues.append(
429
+ self._create_condition_issue(
430
+ statement,
431
+ statement_idx,
432
+ condition_requirement,
433
+ matching_principals,
434
+ config,
435
+ requirement,
436
+ )
437
+ )
438
+ return issues
439
+
440
+ # Handle all_of/any_of/none_of format
441
+ if isinstance(required_conditions_config, dict):
442
+ all_of = required_conditions_config.get("all_of", [])
443
+ any_of = required_conditions_config.get("any_of", [])
444
+ none_of = required_conditions_config.get("none_of", [])
445
+
446
+ # Validate all_of: ALL conditions must be present
447
+ if all_of:
448
+ for condition_requirement in all_of:
449
+ if not self._has_condition_requirement(statement, condition_requirement):
450
+ issues.append(
451
+ self._create_condition_issue(
452
+ statement,
453
+ statement_idx,
454
+ condition_requirement,
455
+ matching_principals,
456
+ config,
457
+ requirement,
458
+ requirement_type="all_of",
459
+ )
460
+ )
461
+
462
+ # Validate any_of: At least ONE condition must be present
463
+ if any_of:
464
+ any_present = any(
465
+ self._has_condition_requirement(statement, cond_req) for cond_req in any_of
466
+ )
467
+
468
+ if not any_present:
469
+ # Create a combined error for any_of
470
+ condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
471
+ severity = requirement.get("severity", self.get_severity(config))
472
+ issues.append(
473
+ ValidationIssue(
474
+ severity=severity,
475
+ statement_sid=statement.sid,
476
+ statement_index=statement_idx,
477
+ issue_type="missing_principal_condition_any_of",
478
+ message=(
479
+ f"Principals {matching_principals} require at least ONE of these conditions: "
480
+ f"{', '.join(condition_keys)}"
481
+ ),
482
+ suggestion=self._build_any_of_suggestion(any_of),
483
+ line_number=statement.line_number,
484
+ )
485
+ )
486
+
487
+ # Validate none_of: NONE of these conditions should be present
488
+ if none_of:
489
+ for condition_requirement in none_of:
490
+ if self._has_condition_requirement(statement, condition_requirement):
491
+ issues.append(
492
+ self._create_none_of_condition_issue(
493
+ statement,
494
+ statement_idx,
495
+ condition_requirement,
496
+ matching_principals,
497
+ config,
498
+ requirement,
499
+ )
500
+ )
501
+
502
+ return issues
503
+
504
+ def _has_condition_requirement(
505
+ self, statement: Statement, condition_requirement: dict[str, Any]
506
+ ) -> bool:
507
+ """Check if statement has the required condition.
508
+
509
+ Args:
510
+ statement: The statement to check
511
+ condition_requirement: Condition requirement config
512
+
513
+ Returns:
514
+ True if condition is present and matches requirements
515
+ """
516
+ condition_key = condition_requirement.get("condition_key")
517
+ if not condition_key:
518
+ return True # No condition key specified, skip
519
+
520
+ operator = condition_requirement.get("operator")
521
+ expected_value = condition_requirement.get("expected_value")
522
+
523
+ return self._has_condition(statement, condition_key, operator, expected_value)
524
+
525
+ def _has_condition(
526
+ self,
527
+ statement: Statement,
528
+ condition_key: str,
529
+ operator: str | None = None,
530
+ expected_value: Any = None,
531
+ ) -> bool:
532
+ """Check if statement has the specified condition key.
533
+
534
+ Args:
535
+ statement: The IAM policy statement
536
+ condition_key: The condition key to look for
537
+ operator: Optional specific operator (e.g., "StringEquals")
538
+ expected_value: Optional expected value for the condition
539
+
540
+ Returns:
541
+ True if condition is present (and matches expected value if specified)
542
+ """
543
+ if not statement.condition:
544
+ return False
545
+
546
+ # If operator specified, only check that operator
547
+ operators_to_check = [operator] if operator else list(statement.condition.keys())
548
+
549
+ # Look through specified condition operators
550
+ for op in operators_to_check:
551
+ if op not in statement.condition:
552
+ continue
553
+
554
+ conditions = statement.condition[op]
555
+ if isinstance(conditions, dict):
556
+ if condition_key in conditions:
557
+ # If no expected value specified, just presence is enough
558
+ if expected_value is None:
559
+ return True
560
+
561
+ # Check if the value matches
562
+ actual_value = conditions[condition_key]
563
+
564
+ # Handle boolean values
565
+ if isinstance(expected_value, bool):
566
+ if isinstance(actual_value, bool):
567
+ return actual_value == expected_value
568
+ if isinstance(actual_value, str):
569
+ return actual_value.lower() == str(expected_value).lower()
570
+
571
+ # Handle exact matches
572
+ if actual_value == expected_value:
573
+ return True
574
+
575
+ # Handle list values (actual can be string or list)
576
+ if isinstance(expected_value, list):
577
+ if isinstance(actual_value, list):
578
+ return set(expected_value) == set(actual_value)
579
+ if actual_value in expected_value:
580
+ return True
581
+
582
+ # Handle string matches for variable references like ${aws:PrincipalTag/owner}
583
+ if str(actual_value) == str(expected_value):
584
+ return True
585
+
586
+ return False
587
+
588
+ def _create_condition_issue(
589
+ self,
590
+ statement: Statement,
591
+ statement_idx: int,
592
+ condition_requirement: dict[str, Any],
593
+ matching_principals: list[str],
594
+ config: CheckConfig,
595
+ requirement: dict[str, Any],
596
+ requirement_type: str = "required",
597
+ ) -> ValidationIssue:
598
+ """Create a validation issue for a missing condition.
599
+
600
+ Severity precedence:
601
+ 1. Individual condition requirement's severity (condition_requirement['severity'])
602
+ 2. Parent requirement's severity (requirement['severity'])
603
+ 3. Global check severity (config.severity)
604
+
605
+ Args:
606
+ statement: The statement being validated
607
+ statement_idx: Index of the statement
608
+ condition_requirement: The condition requirement config
609
+ matching_principals: Principals that matched
610
+ config: Check configuration
611
+ requirement: Parent requirement config
612
+ requirement_type: Type of requirement (required, all_of)
613
+
614
+ Returns:
615
+ ValidationIssue
616
+ """
617
+ condition_key = condition_requirement.get("condition_key", "unknown")
618
+ description = condition_requirement.get("description", "")
619
+ expected_value = condition_requirement.get("expected_value")
620
+ example = condition_requirement.get("example", "")
621
+ operator = condition_requirement.get("operator", "StringEquals")
622
+
623
+ message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
624
+
625
+ # Determine severity with precedence: condition > requirement > global
626
+ severity = (
627
+ condition_requirement.get("severity")
628
+ or requirement.get("severity")
629
+ or self.get_severity(config)
630
+ )
631
+
632
+ return ValidationIssue(
633
+ severity=severity,
634
+ statement_sid=statement.sid,
635
+ statement_index=statement_idx,
636
+ issue_type="missing_principal_condition",
637
+ message=f"{message_prefix} Principal(s) {matching_principals} require condition '{condition_key}'",
638
+ suggestion=self._build_condition_suggestion(
639
+ condition_key, description, example, expected_value, operator
640
+ ),
641
+ line_number=statement.line_number,
642
+ )
643
+
644
+ def _build_condition_suggestion(
645
+ self,
646
+ condition_key: str,
647
+ description: str,
648
+ example: str,
649
+ expected_value: Any = None,
650
+ operator: str = "StringEquals",
651
+ ) -> str:
652
+ """Build a helpful suggestion for adding the missing condition.
653
+
654
+ Args:
655
+ condition_key: The condition key
656
+ description: Description of the condition
657
+ example: Example usage
658
+ expected_value: Expected value for the condition
659
+ operator: Condition operator
660
+
661
+ Returns:
662
+ Suggestion string
663
+ """
664
+ parts = []
665
+
666
+ if description:
667
+ parts.append(description)
668
+
669
+ # Build example based on condition key type
670
+ if example:
671
+ parts.append(f"Example:\n```json\n{example}\n```")
672
+ else:
673
+ # Auto-generate example
674
+ example_lines = ['Add to "Condition" block:', f' "{operator}": {{']
675
+
676
+ if isinstance(expected_value, list):
677
+ value_str = (
678
+ "["
679
+ + ", ".join(
680
+ [
681
+ f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
682
+ for v in expected_value
683
+ ]
684
+ )
685
+ + "]"
686
+ )
687
+ elif expected_value is not None:
688
+ # Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
689
+ if str(expected_value).startswith("${"):
690
+ value_str = f'"{expected_value}"'
691
+ elif isinstance(expected_value, bool):
692
+ value_str = str(expected_value).lower()
693
+ else:
694
+ value_str = f'"{expected_value}"'
695
+ else:
696
+ value_str = '"<value>"'
697
+
698
+ example_lines.append(f' "{condition_key}": {value_str}')
699
+ example_lines.append(" }")
700
+
701
+ parts.append("\n".join(example_lines))
702
+
703
+ return ". ".join(parts) if parts else f"Add condition: {condition_key}"
704
+
705
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
706
+ """Build suggestion for any_of conditions.
707
+
708
+ Args:
709
+ any_of_conditions: List of condition requirements
710
+
711
+ Returns:
712
+ Suggestion string
713
+ """
714
+ suggestions = []
715
+ suggestions.append("Add at least ONE of these conditions:")
716
+
717
+ for i, cond in enumerate(any_of_conditions, 1):
718
+ condition_key = cond.get("condition_key", "unknown")
719
+ description = cond.get("description", "")
720
+ expected_value = cond.get("expected_value")
721
+
722
+ option = f"\nOption {i}: {condition_key}"
723
+ if description:
724
+ option += f" - {description}"
725
+ if expected_value is not None:
726
+ option += f" (value: {expected_value})"
727
+
728
+ suggestions.append(option)
729
+
730
+ return "".join(suggestions)
731
+
732
+ def _create_none_of_condition_issue(
733
+ self,
734
+ statement: Statement,
735
+ statement_idx: int,
736
+ condition_requirement: dict[str, Any],
737
+ matching_principals: list[str],
738
+ config: CheckConfig,
739
+ requirement: dict[str, Any],
740
+ ) -> ValidationIssue:
741
+ """Create a validation issue for a forbidden condition that is present.
742
+
743
+ Args:
744
+ statement: The statement being validated
745
+ statement_idx: Index of the statement
746
+ condition_requirement: The condition requirement config
747
+ matching_principals: Principals that matched
748
+ config: Check configuration
749
+ requirement: Parent requirement config
750
+
751
+ Returns:
752
+ ValidationIssue
753
+ """
754
+ condition_key = condition_requirement.get("condition_key", "unknown")
755
+ description = condition_requirement.get("description", "")
756
+ expected_value = condition_requirement.get("expected_value")
757
+
758
+ message = f"FORBIDDEN: Principal(s) {matching_principals} must NOT have condition '{condition_key}'"
759
+ if expected_value is not None:
760
+ message += f" with value '{expected_value}'"
761
+
762
+ suggestion = f"Remove the '{condition_key}' condition from the statement"
763
+ if description:
764
+ suggestion += f". {description}"
765
+
766
+ severity = requirement.get("severity", self.get_severity(config))
767
+
768
+ return ValidationIssue(
769
+ severity=severity,
770
+ statement_sid=statement.sid,
771
+ statement_index=statement_idx,
772
+ issue_type="forbidden_principal_condition",
773
+ message=message,
774
+ suggestion=suggestion,
775
+ line_number=statement.line_number,
776
+ )