iam-policy-validator 1.14.0__py3-none-any.whl

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