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,509 @@
1
+ """Trust Policy Validation Check.
2
+
3
+ Validates trust policies (role assumption policies) for security best practices.
4
+ This check ensures that assume role actions have appropriate principals and conditions.
5
+
6
+ Trust policies are resource-based policies attached to IAM roles that control
7
+ who can assume the role and under what conditions.
8
+
9
+ Key Validations:
10
+ 1. Action-Principal Type Matching
11
+ - sts:AssumeRole → AWS or Service principals
12
+ - sts:AssumeRoleWithSAML → Federated (SAML provider) principals
13
+ - sts:AssumeRoleWithWebIdentity → Federated (OIDC provider) principals
14
+
15
+ 2. Provider ARN Validation
16
+ - SAML providers must match: arn:aws:iam::account:saml-provider/name
17
+ - OIDC providers must match: arn:aws:iam::account:oidc-provider/domain
18
+
19
+ 3. Required Conditions
20
+ - SAML: Requires SAML:aud condition
21
+ - OIDC: Requires provider-specific audience/subject conditions
22
+ - Cross-account: Should have ExternalId or PrincipalOrgID
23
+
24
+ Complements existing checks:
25
+ - principal_validation: Validates which principals are allowed/blocked
26
+ - action_condition_enforcement: Validates required conditions for actions
27
+ - trust_policy_validation: Validates action-principal coupling and trust-specific rules
28
+
29
+ This check is DISABLED by default. Enable it for trust policy validation:
30
+
31
+ trust_policy_validation:
32
+ enabled: true
33
+ severity: high
34
+ """
35
+
36
+ import re
37
+ from typing import TYPE_CHECKING, Any, ClassVar
38
+
39
+ from iam_validator.core.aws_service import AWSServiceFetcher
40
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
41
+ from iam_validator.core.models import Statement, ValidationIssue
42
+
43
+ if TYPE_CHECKING:
44
+ pass
45
+
46
+
47
+ class TrustPolicyValidationCheck(PolicyCheck):
48
+ """Validates trust policies for role assumption security."""
49
+
50
+ # Default validation rules for assume actions
51
+ DEFAULT_RULES = {
52
+ "sts:AssumeRole": {
53
+ "allowed_principal_types": ["AWS", "Service"],
54
+ "description": "Standard role assumption",
55
+ },
56
+ "sts:AssumeRoleWithSAML": {
57
+ "allowed_principal_types": ["Federated"],
58
+ "provider_pattern": r"^arn:aws:iam::\d{12}:saml-provider/[\w+=,.@-]+$",
59
+ "required_conditions": ["SAML:aud"],
60
+ "description": "SAML-based federated role assumption",
61
+ },
62
+ "sts:AssumeRoleWithWebIdentity": {
63
+ "allowed_principal_types": ["Federated"],
64
+ "provider_pattern": r"^arn:aws:iam::\d{12}:oidc-provider/[\w./-]+$",
65
+ "required_conditions": ["*:aud"], # Require audience condition (provider-specific key)
66
+ "description": "OIDC-based federated role assumption",
67
+ },
68
+ "sts:TagSession": {
69
+ "allowed_principal_types": ["AWS", "Service", "Federated"],
70
+ "description": "Session tagging during role assumption (can be combined with any assume action)",
71
+ },
72
+ "sts:SetSourceIdentity": {
73
+ "allowed_principal_types": ["AWS", "Service", "Federated"],
74
+ "description": "Set source identity during role assumption (tracks original identity through role chains)",
75
+ },
76
+ "sts:SetContext": {
77
+ "allowed_principal_types": ["AWS", "Service", "Federated"],
78
+ "description": "Set session context during role assumption",
79
+ },
80
+ }
81
+
82
+ check_id: ClassVar[str] = "trust_policy_validation"
83
+ description: ClassVar[str] = (
84
+ "Validates trust policies for role assumption security and action-principal coupling"
85
+ )
86
+ default_severity: ClassVar[str] = "high"
87
+
88
+ async def execute(
89
+ self,
90
+ statement: Statement,
91
+ statement_idx: int,
92
+ fetcher: AWSServiceFetcher,
93
+ config: CheckConfig,
94
+ ) -> list[ValidationIssue]:
95
+ """Execute trust policy validation on a single statement.
96
+
97
+ Args:
98
+ statement: The statement to validate
99
+ statement_idx: Index of the statement in the policy
100
+ fetcher: AWS service fetcher instance
101
+ config: Configuration for this check
102
+
103
+ Returns:
104
+ List of validation issues
105
+ """
106
+ issues = []
107
+
108
+ # Skip if no principal (trust policies must have principals)
109
+ if statement.principal is None and statement.not_principal is None:
110
+ return issues
111
+
112
+ # Get actions from statement
113
+ actions = self._get_actions(statement)
114
+ if not actions:
115
+ return issues
116
+
117
+ # Get validation rules (use custom rules if provided, otherwise defaults)
118
+ validation_rules = config.config.get("validation_rules", self.DEFAULT_RULES)
119
+
120
+ # Check each assume action
121
+ for action in actions:
122
+ # Skip wildcard actions (too broad to validate specifically)
123
+ if action == "*" or action == "sts:*":
124
+ continue
125
+
126
+ # Find matching rule (exact matches for assume actions)
127
+ rule = self._find_matching_rule(action, validation_rules)
128
+ if not rule:
129
+ continue # Not an assume action we validate
130
+
131
+ # Validate principal type for this action
132
+ principal_issues = self._validate_principal_type(
133
+ statement, action, rule, statement_idx, config
134
+ )
135
+ issues.extend(principal_issues)
136
+
137
+ # Validate provider ARN format if required
138
+ if "provider_pattern" in rule:
139
+ provider_issues = self._validate_provider_format(
140
+ statement, action, rule, statement_idx, config
141
+ )
142
+ issues.extend(provider_issues)
143
+
144
+ # Validate required conditions
145
+ if "required_conditions" in rule:
146
+ condition_issues = self._validate_required_conditions(
147
+ statement, action, rule, statement_idx, config
148
+ )
149
+ issues.extend(condition_issues)
150
+
151
+ return issues
152
+
153
+ def _get_actions(self, statement: Statement) -> list[str]:
154
+ """Extract actions from statement.
155
+
156
+ Args:
157
+ statement: IAM policy statement
158
+
159
+ Returns:
160
+ List of action strings
161
+ """
162
+ if statement.action is None:
163
+ return []
164
+ return [statement.action] if isinstance(statement.action, str) else statement.action
165
+
166
+ def _find_matching_rule(self, action: str, rules: dict[str, Any]) -> dict[str, Any] | None:
167
+ """Find validation rule matching the action.
168
+
169
+ Supports wildcards in action names.
170
+
171
+ Args:
172
+ action: Action to find rule for (e.g., "sts:AssumeRole")
173
+ rules: Validation rules dict
174
+
175
+ Returns:
176
+ Matching rule dict or None
177
+ """
178
+ # Exact match first (performance optimization)
179
+ if action in rules:
180
+ return rules[action]
181
+
182
+ # Check for wildcard patterns in action
183
+ for rule_action, rule_config in rules.items():
184
+ # Support wildcards in the action being validated
185
+ if "*" in action:
186
+ pattern = action.replace("*", ".*")
187
+ if re.match(f"^{pattern}$", rule_action):
188
+ return rule_config
189
+
190
+ return None
191
+
192
+ def _extract_principal_types(self, statement: Statement) -> dict[str, list[str]]:
193
+ """Extract principals grouped by type (AWS, Service, Federated, etc.).
194
+
195
+ Args:
196
+ statement: IAM policy statement
197
+
198
+ Returns:
199
+ Dict mapping principal type to list of principal values
200
+ """
201
+ principal_types: dict[str, list[str]] = {}
202
+
203
+ if statement.principal:
204
+ if isinstance(statement.principal, str):
205
+ # Simple string principal like "*"
206
+ principal_types["AWS"] = [statement.principal]
207
+ elif isinstance(statement.principal, dict):
208
+ for key, value in statement.principal.items():
209
+ if isinstance(value, str):
210
+ principal_types[key] = [value]
211
+ elif isinstance(value, list):
212
+ principal_types[key] = value
213
+
214
+ return principal_types
215
+
216
+ def _validate_principal_type(
217
+ self,
218
+ statement: Statement,
219
+ action: str,
220
+ rule: dict[str, Any],
221
+ statement_idx: int,
222
+ config: CheckConfig,
223
+ ) -> list[ValidationIssue]:
224
+ """Validate that principal type matches the assume action.
225
+
226
+ Args:
227
+ statement: IAM policy statement
228
+ action: Assume action being validated
229
+ rule: Validation rule for this action
230
+ statement_idx: Statement index
231
+
232
+ Returns:
233
+ List of validation issues
234
+ """
235
+ issues = []
236
+
237
+ allowed_types = rule.get("allowed_principal_types", [])
238
+ if not allowed_types:
239
+ return issues
240
+
241
+ principal_types = self._extract_principal_types(statement)
242
+
243
+ # Check if any principal type is not allowed
244
+ for principal_type, principals in principal_types.items():
245
+ if principal_type not in allowed_types:
246
+ principals_list = ", ".join(f"`{p}`" for p in principals)
247
+ allowed_list = ", ".join(f"`{t}`" for t in allowed_types)
248
+
249
+ issues.append(
250
+ ValidationIssue(
251
+ severity=self.get_severity(config),
252
+ issue_type="invalid_principal_type_for_assume_action",
253
+ message=f"Action `{action}` should not use `Principal` type `{principal_type}`. "
254
+ f"Expected principal types: {allowed_list}",
255
+ statement_index=statement_idx,
256
+ statement_sid=statement.sid,
257
+ line_number=statement.line_number,
258
+ action=action,
259
+ suggestion=f"For `{action}`, use {allowed_list} principal type instead of `{principal_type}`. "
260
+ f"\n\nFound principals: `{principals_list}`\n\n"
261
+ f"{rule.get('description', '')}",
262
+ example=self._get_example_for_action(
263
+ action, allowed_types[0] if allowed_types else "AWS"
264
+ ),
265
+ field_name="principal",
266
+ )
267
+ )
268
+
269
+ return issues
270
+
271
+ def _validate_provider_format(
272
+ self,
273
+ statement: Statement,
274
+ action: str,
275
+ rule: dict[str, Any],
276
+ statement_idx: int,
277
+ config: CheckConfig,
278
+ ) -> list[ValidationIssue]:
279
+ """Validate that federated provider ARN matches expected format.
280
+
281
+ Args:
282
+ statement: IAM policy statement
283
+ action: Assume action being validated
284
+ rule: Validation rule for this action
285
+ statement_idx: Statement index
286
+
287
+ Returns:
288
+ List of validation issues
289
+ """
290
+ issues = []
291
+
292
+ provider_pattern = rule.get("provider_pattern")
293
+ if not provider_pattern:
294
+ return issues
295
+
296
+ principal_types = self._extract_principal_types(statement)
297
+ federated_principals = principal_types.get("Federated", [])
298
+
299
+ for principal in federated_principals:
300
+ if not re.match(provider_pattern, principal):
301
+ provider_type = "SAML" if "saml-provider" in provider_pattern else "OIDC"
302
+
303
+ issues.append(
304
+ ValidationIssue(
305
+ severity=self.get_severity(config),
306
+ issue_type="invalid_provider_format",
307
+ message=f"Federated principal `{principal}` does not match expected `{provider_type}` provider format for `{action}`",
308
+ statement_index=statement_idx,
309
+ statement_sid=statement.sid,
310
+ line_number=statement.line_number,
311
+ action=action,
312
+ suggestion=f"For `{action}`, use a valid `{provider_type}` provider ARN.\n\n"
313
+ f"Expected pattern: `{provider_pattern}`\n"
314
+ f"Found: `{principal}`",
315
+ example=self._get_provider_example(provider_type),
316
+ field_name="principal",
317
+ )
318
+ )
319
+
320
+ return issues
321
+
322
+ def _validate_required_conditions(
323
+ self,
324
+ statement: Statement,
325
+ action: str,
326
+ rule: dict[str, Any],
327
+ statement_idx: int,
328
+ config: CheckConfig,
329
+ ) -> list[ValidationIssue]:
330
+ """Validate that required conditions are present.
331
+
332
+ Args:
333
+ statement: IAM policy statement
334
+ action: Assume action being validated
335
+ rule: Validation rule for this action
336
+ statement_idx: Statement index
337
+
338
+ Returns:
339
+ List of validation issues
340
+ """
341
+ issues = []
342
+
343
+ required_conditions = rule.get("required_conditions", [])
344
+ if not required_conditions:
345
+ return issues
346
+
347
+ # Get all condition keys from statement
348
+ condition_keys = set()
349
+ if statement.condition:
350
+ for _operator, keys_dict in statement.condition.items():
351
+ if isinstance(keys_dict, dict):
352
+ condition_keys.update(keys_dict.keys())
353
+
354
+ # Check for missing required conditions (supports wildcards like *:aud)
355
+ missing_conditions = []
356
+ for required_cond in required_conditions:
357
+ if "*:" in required_cond:
358
+ # Wildcard pattern - check if any key ends with the suffix
359
+ suffix = required_cond.split("*:")[1]
360
+ if not any(key.endswith(f":{suffix}") for key in condition_keys):
361
+ missing_conditions.append(required_cond)
362
+ else:
363
+ # Exact match
364
+ if required_cond not in condition_keys:
365
+ missing_conditions.append(required_cond)
366
+
367
+ if missing_conditions:
368
+ missing_list = ", ".join(f"`{c}`" for c in missing_conditions)
369
+
370
+ issues.append(
371
+ ValidationIssue(
372
+ severity=self.get_severity(config),
373
+ issue_type="missing_required_condition_for_assume_action",
374
+ message=f"Action `{action}` is missing required conditions: `{missing_list}`",
375
+ statement_index=statement_idx,
376
+ statement_sid=statement.sid,
377
+ line_number=statement.line_number,
378
+ action=action,
379
+ suggestion=f"Add required condition(s) to restrict when `{action}` can be performed. "
380
+ f"Missing: `{missing_list}`\n\n"
381
+ f"{rule.get('description', '')}",
382
+ example=self._get_condition_example(action, required_conditions[0]),
383
+ field_name="condition",
384
+ )
385
+ )
386
+
387
+ return issues
388
+
389
+ def _get_example_for_action(self, action: str, principal_type: str) -> str:
390
+ """Generate example JSON for an assume action.
391
+
392
+ Args:
393
+ action: Assume action
394
+ principal_type: Expected principal type
395
+
396
+ Returns:
397
+ JSON example string
398
+ """
399
+ examples = {
400
+ ("sts:AssumeRole", "AWS"): """{
401
+ "Effect": "Allow",
402
+ "Principal": {
403
+ "AWS": "arn:aws:iam::123456789012:root"
404
+ },
405
+ "Action": "sts:AssumeRole",
406
+ "Condition": {
407
+ "StringEquals": {
408
+ "sts:ExternalId": "unique-external-id"
409
+ }
410
+ }
411
+ }""",
412
+ ("sts:AssumeRole", "Service"): """{
413
+ "Effect": "Allow",
414
+ "Principal": {
415
+ "Service": "lambda.amazonaws.com"
416
+ },
417
+ "Action": "sts:AssumeRole"
418
+ }""",
419
+ ("sts:AssumeRoleWithSAML", "Federated"): """{
420
+ "Effect": "Allow",
421
+ "Principal": {
422
+ "Federated": "arn:aws:iam::123456789012:saml-provider/MyProvider"
423
+ },
424
+ "Action": "sts:AssumeRoleWithSAML",
425
+ "Condition": {
426
+ "StringEquals": {
427
+ "SAML:aud": "https://signin.aws.amazon.com/saml"
428
+ }
429
+ }
430
+ }""",
431
+ ("sts:AssumeRoleWithWebIdentity", "Federated"): """{
432
+ "Effect": "Allow",
433
+ "Principal": {
434
+ "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
435
+ },
436
+ "Action": "sts:AssumeRoleWithWebIdentity",
437
+ "Condition": {
438
+ "StringEquals": {
439
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
440
+ },
441
+ "StringLike": {
442
+ "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
443
+ }
444
+ }
445
+ }""",
446
+ }
447
+
448
+ return examples.get((action, principal_type), "")
449
+
450
+ def _get_provider_example(self, provider_type: str) -> str:
451
+ """Get example provider ARN.
452
+
453
+ Args:
454
+ provider_type: Type of provider (SAML or OIDC)
455
+
456
+ Returns:
457
+ Example ARN string
458
+ """
459
+ if provider_type == "SAML":
460
+ return """{
461
+ "Principal": {
462
+ "Federated": "arn:aws:iam::123456789012:saml-provider/MyProvider"
463
+ }
464
+ }"""
465
+ else: # OIDC
466
+ return """{
467
+ "Principal": {
468
+ "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
469
+ }
470
+ }"""
471
+
472
+ def _get_condition_example(self, action: str, condition_key: str) -> str:
473
+ """Get example condition for an action.
474
+
475
+ Args:
476
+ action: Assume action
477
+ condition_key: Required condition key
478
+
479
+ Returns:
480
+ JSON example string
481
+ """
482
+ examples = {
483
+ "SAML:aud": """{
484
+ "Condition": {
485
+ "StringEquals": {
486
+ "SAML:aud": "https://signin.aws.amazon.com/saml"
487
+ }
488
+ }
489
+ }""",
490
+ "sts:ExternalId": """{
491
+ "Condition": {
492
+ "StringEquals": {
493
+ "sts:ExternalId": "unique-external-id-shared-with-trusted-party"
494
+ }
495
+ }
496
+ }""",
497
+ "aws:PrincipalOrgID": """{
498
+ "Condition": {
499
+ "StringEquals": {
500
+ "aws:PrincipalOrgID": "o-123456789"
501
+ }
502
+ }
503
+ }""",
504
+ }
505
+
506
+ return examples.get(
507
+ condition_key,
508
+ f'{{\n "Condition": {{\n "StringEquals": {{\n "{condition_key}": "value"\n }}\n }}\n}}',
509
+ )
@@ -0,0 +1,17 @@
1
+ """Utility modules for IAM policy checks."""
2
+
3
+ from iam_validator.checks.utils.action_parser import (
4
+ ParsedAction,
5
+ extract_service,
6
+ get_action_case_insensitive,
7
+ is_wildcard_action,
8
+ parse_action,
9
+ )
10
+
11
+ __all__ = [
12
+ "ParsedAction",
13
+ "extract_service",
14
+ "get_action_case_insensitive",
15
+ "is_wildcard_action",
16
+ "parse_action",
17
+ ]
@@ -0,0 +1,149 @@
1
+ """Action parsing utility for IAM policy validation.
2
+
3
+ This module provides a consistent way to parse AWS IAM action names
4
+ (format: service:ActionName) across all validation checks.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import TypeVar
9
+
10
+ # Type variable for generic dictionary value lookup
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ParsedAction:
16
+ """Represents a parsed AWS IAM action.
17
+
18
+ Attributes:
19
+ service: The AWS service prefix (e.g., "s3", "ec2", "iam")
20
+ action_name: The action name (e.g., "GetObject", "DescribeInstances")
21
+ has_wildcard: True if the service or action contains "*"
22
+ original: The original action string as provided
23
+ """
24
+
25
+ service: str
26
+ action_name: str
27
+ has_wildcard: bool
28
+ original: str
29
+
30
+
31
+ def parse_action(action: str) -> ParsedAction | None:
32
+ """Parse an AWS IAM action string into its components.
33
+
34
+ AWS IAM actions follow the format "service:ActionName" where:
35
+ - service is the AWS service prefix (case-insensitive, typically lowercase)
36
+ - ActionName is the specific API action (PascalCase or camelCase)
37
+
38
+ Args:
39
+ action: The action string to parse (e.g., "s3:GetObject", "ec2:*")
40
+
41
+ Returns:
42
+ ParsedAction if the action is valid, None if malformed.
43
+
44
+ Examples:
45
+ >>> parse_action("s3:GetObject")
46
+ ParsedAction(service="s3", action_name="GetObject", has_wildcard=False, original="s3:GetObject")
47
+
48
+ >>> parse_action("ec2:Describe*")
49
+ ParsedAction(service="ec2", action_name="Describe*", has_wildcard=True, original="ec2:Describe*")
50
+
51
+ >>> parse_action("InvalidAction")
52
+ None
53
+
54
+ >>> parse_action("*")
55
+ None
56
+ """
57
+ # Handle full wildcard - not a parseable service:action
58
+ if action == "*":
59
+ return None
60
+
61
+ # Must contain exactly one colon separating service and action
62
+ if ":" not in action:
63
+ return None
64
+
65
+ # Split on first colon only (action names can theoretically contain colons)
66
+ parts = action.split(":", 1)
67
+ if len(parts) != 2:
68
+ return None
69
+
70
+ service, action_name = parts
71
+
72
+ # Both service and action name must be non-empty
73
+ if not service or not action_name:
74
+ return None
75
+
76
+ return ParsedAction(
77
+ service=service,
78
+ action_name=action_name,
79
+ has_wildcard="*" in service or "*" in action_name,
80
+ original=action,
81
+ )
82
+
83
+
84
+ def is_wildcard_action(action: str) -> bool:
85
+ """Check if an action contains a wildcard.
86
+
87
+ Args:
88
+ action: The action string to check
89
+
90
+ Returns:
91
+ True if the action is "*" or contains "*" in service or action name
92
+ """
93
+ if action == "*":
94
+ return True
95
+
96
+ parsed = parse_action(action)
97
+ return parsed.has_wildcard if parsed else False
98
+
99
+
100
+ def extract_service(action: str) -> str | None:
101
+ """Extract the service prefix from an action string.
102
+
103
+ Args:
104
+ action: The action string (e.g., "s3:GetObject")
105
+
106
+ Returns:
107
+ The service prefix (e.g., "s3") or None if the action is malformed
108
+ """
109
+ if action == "*":
110
+ return None
111
+
112
+ parsed = parse_action(action)
113
+ return parsed.service if parsed else None
114
+
115
+
116
+ def get_action_case_insensitive(actions_dict: dict[str, T], action_name: str) -> T | None:
117
+ """Look up an action in a dictionary using case-insensitive matching.
118
+
119
+ AWS action names are case-insensitive, but our service definitions may have
120
+ canonical casing. This function tries exact match first, then falls back
121
+ to case-insensitive lookup.
122
+
123
+ Args:
124
+ actions_dict: Dictionary mapping action names to values (e.g., ActionDetail)
125
+ action_name: The action name to look up
126
+
127
+ Returns:
128
+ The value if found, None otherwise
129
+
130
+ Examples:
131
+ >>> actions = {"GetObject": detail, "PutObject": detail2}
132
+ >>> get_action_case_insensitive(actions, "GetObject") # Exact match
133
+ detail
134
+ >>> get_action_case_insensitive(actions, "getobject") # Case-insensitive
135
+ detail
136
+ >>> get_action_case_insensitive(actions, "Unknown")
137
+ None
138
+ """
139
+ # Try exact match first (most common case)
140
+ if action_name in actions_dict:
141
+ return actions_dict[action_name]
142
+
143
+ # Fall back to case-insensitive lookup
144
+ action_name_lower = action_name.lower()
145
+ for key, value in actions_dict.items():
146
+ if key.lower() == action_name_lower:
147
+ return value
148
+
149
+ return None