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,135 @@
1
+ """Resource validation check - validates ARN formats."""
2
+
3
+ import re
4
+ from typing import ClassVar
5
+
6
+ from iam_validator.core.aws_service import AWSServiceFetcher
7
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
8
+ from iam_validator.core.constants import DEFAULT_ARN_VALIDATION_PATTERN, MAX_ARN_LENGTH
9
+ from iam_validator.core.models import Statement, ValidationIssue
10
+ from iam_validator.sdk.arn_matching import (
11
+ has_template_variables,
12
+ normalize_template_variables,
13
+ )
14
+
15
+
16
+ class ResourceValidationCheck(PolicyCheck):
17
+ """Validates ARN format for resources."""
18
+
19
+ check_id: ClassVar[str] = "resource_validation"
20
+ description: ClassVar[str] = "Validates ARN format for resources"
21
+ default_severity: ClassVar[str] = "error"
22
+
23
+ async def execute(
24
+ self,
25
+ statement: Statement,
26
+ statement_idx: int,
27
+ fetcher: AWSServiceFetcher,
28
+ config: CheckConfig,
29
+ ) -> list[ValidationIssue]:
30
+ """Execute resource ARN validation on a statement."""
31
+ issues = []
32
+
33
+ # Get resources from statement
34
+ resources = statement.get_resources()
35
+ statement_sid = statement.sid
36
+ line_number = statement.line_number
37
+
38
+ # Get ARN pattern from config, or use default
39
+ # Pattern allows wildcards (*) in region and account fields
40
+ arn_pattern_str = config.config.get("arn_pattern", DEFAULT_ARN_VALIDATION_PATTERN)
41
+
42
+ # Compile pattern
43
+ try:
44
+ arn_pattern = re.compile(arn_pattern_str)
45
+ except re.error:
46
+ # Fallback to default pattern if custom pattern is invalid
47
+ arn_pattern = re.compile(DEFAULT_ARN_VALIDATION_PATTERN)
48
+
49
+ # Check if template variable support is enabled (default: true)
50
+ # Try global settings first, then check-specific config
51
+ allow_template_variables = config.root_config.get("settings", {}).get(
52
+ "allow_template_variables",
53
+ config.config.get("allow_template_variables", True),
54
+ )
55
+
56
+ for resource in resources:
57
+ # Skip wildcard resources (handled by security checks)
58
+ if resource == "*":
59
+ continue
60
+
61
+ # Validate ARN length to prevent ReDoS attacks
62
+ if len(resource) > MAX_ARN_LENGTH:
63
+ issues.append(
64
+ ValidationIssue(
65
+ severity=self.get_severity(config),
66
+ statement_sid=statement_sid,
67
+ statement_index=statement_idx,
68
+ issue_type="invalid_resource",
69
+ message=f"Resource ARN exceeds maximum length ({len(resource)} > {MAX_ARN_LENGTH}): {resource[:100]}...",
70
+ resource=resource[:100] + "...",
71
+ suggestion="`ARN` is too long and may be invalid",
72
+ line_number=line_number,
73
+ field_name="resource",
74
+ )
75
+ )
76
+ continue
77
+
78
+ # Check if resource contains template variables
79
+ has_templates = has_template_variables(resource)
80
+
81
+ # If template variables are found and allowed, normalize them for validation
82
+ validation_resource = resource
83
+ if has_templates and allow_template_variables:
84
+ validation_resource = normalize_template_variables(resource)
85
+
86
+ # Validate ARN format
87
+ try:
88
+ if not arn_pattern.match(validation_resource):
89
+ # If original resource had templates and normalization didn't help,
90
+ # provide a more informative message
91
+ if has_templates and allow_template_variables:
92
+ issues.append(
93
+ ValidationIssue(
94
+ severity=self.get_severity(config),
95
+ statement_sid=statement_sid,
96
+ statement_index=statement_idx,
97
+ issue_type="invalid_resource",
98
+ message=f"Invalid `ARN` format even after normalizing template variables: `{resource}`",
99
+ resource=resource,
100
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource` (template variables like `${aws_account_id}` are supported)",
101
+ line_number=line_number,
102
+ field_name="resource",
103
+ )
104
+ )
105
+ else:
106
+ issues.append(
107
+ ValidationIssue(
108
+ severity=self.get_severity(config),
109
+ statement_sid=statement_sid,
110
+ statement_index=statement_idx,
111
+ issue_type="invalid_resource",
112
+ message=f"Invalid `ARN` format: `{resource}`",
113
+ resource=resource,
114
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource`",
115
+ line_number=line_number,
116
+ field_name="resource",
117
+ )
118
+ )
119
+ except Exception: # pylint: disable=broad-exception-caught
120
+ # If regex matching fails (shouldn't happen with length check), treat as invalid
121
+ issues.append(
122
+ ValidationIssue(
123
+ severity=self.get_severity(config),
124
+ statement_sid=statement_sid,
125
+ statement_index=statement_idx,
126
+ issue_type="invalid_resource",
127
+ message=f"Could not validate `ARN` format: `{resource}`",
128
+ resource=resource,
129
+ suggestion="`ARN` validation failed - may contain unexpected characters",
130
+ line_number=line_number,
131
+ field_name="resource",
132
+ )
133
+ )
134
+
135
+ return issues
@@ -0,0 +1,438 @@
1
+ """Sensitive action check - detects sensitive actions without IAM conditions."""
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar
4
+
5
+ from iam_validator.checks.utils.policy_level_checks import check_policy_level_actions
6
+ from iam_validator.checks.utils.sensitive_action_matcher import (
7
+ DEFAULT_SENSITIVE_ACTIONS,
8
+ check_sensitive_actions,
9
+ )
10
+ from iam_validator.checks.utils.wildcard_expansion import expand_wildcard_actions
11
+ from iam_validator.core.aws_service import AWSServiceFetcher
12
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
13
+ from iam_validator.core.config.sensitive_actions import get_category_for_action
14
+ from iam_validator.core.models import Statement, ValidationIssue
15
+
16
+ if TYPE_CHECKING:
17
+ from iam_validator.core.models import IAMPolicy
18
+
19
+
20
+ def get_suggestion_from_requirement(requirement: dict[str, Any]) -> tuple[str, str] | None:
21
+ """
22
+ Extract suggestion and example from a condition requirement.
23
+
24
+ This is a public utility function that can be used by custom checks
25
+ to extract user-friendly suggestions from condition requirement structures.
26
+
27
+ Args:
28
+ requirement: Condition requirement dictionary containing:
29
+ - suggestion_text: Human-readable guidance text
30
+ - required_conditions: Conditions structure (list or dict with any_of/all_of/none_of)
31
+
32
+ Returns:
33
+ Tuple of (suggestion_text, example) if available, None otherwise
34
+
35
+ Example:
36
+ >>> from iam_validator.core.config.condition_requirements import IAM_PASS_ROLE_REQUIREMENT
37
+ >>> suggestion, example = get_suggestion_from_requirement(IAM_PASS_ROLE_REQUIREMENT)
38
+ >>> print(suggestion)
39
+ This action allows passing IAM roles to AWS services...
40
+ """
41
+ # Check if requirement has suggestion_text
42
+ if "suggestion_text" not in requirement:
43
+ return None
44
+
45
+ suggestion_text = requirement["suggestion_text"]
46
+
47
+ # Extract example from required_conditions
48
+ example = ""
49
+ required_conditions = requirement.get("required_conditions", [])
50
+
51
+ # Handle different condition structures (list, dict with any_of/all_of/none_of)
52
+ if isinstance(required_conditions, list) and required_conditions:
53
+ # Get first condition's example
54
+ first_condition = required_conditions[0]
55
+ example = first_condition.get("example", "")
56
+ elif isinstance(required_conditions, dict):
57
+ # Handle any_of, all_of, none_of structures
58
+ for logic_key in ["any_of", "all_of", "none_of"]:
59
+ if logic_key in required_conditions:
60
+ conditions = required_conditions[logic_key]
61
+ if isinstance(conditions, list) and conditions:
62
+ # Get first option's example
63
+ first_option = conditions[0]
64
+ if isinstance(first_option, dict):
65
+ if "example" in first_option:
66
+ example = first_option["example"]
67
+ break
68
+ # Handle nested all_of/any_of/none_of structures
69
+ for nested_key in ["all_of", "any_of", "none_of"]:
70
+ if nested_key in first_option and isinstance(
71
+ first_option[nested_key], list
72
+ ):
73
+ for nested in first_option[nested_key]:
74
+ if "example" in nested:
75
+ example = nested["example"]
76
+ break
77
+ if example:
78
+ break
79
+ if example:
80
+ break
81
+
82
+ return (suggestion_text, example)
83
+
84
+
85
+ class SensitiveActionCheck(PolicyCheck):
86
+ """Checks for sensitive actions without IAM conditions to limit their use."""
87
+
88
+ check_id: ClassVar[str] = "sensitive_action"
89
+ description: ClassVar[str] = "Checks for sensitive actions without conditions"
90
+ default_severity: ClassVar[str] = "medium"
91
+
92
+ def _get_severity_for_action(self, action: str, config: CheckConfig) -> str:
93
+ """
94
+ Get severity for a specific action, considering category-based overrides.
95
+
96
+ Args:
97
+ action: The AWS action to check
98
+ config: Check configuration
99
+
100
+ Returns:
101
+ Severity level for the action (considers category overrides)
102
+ """
103
+ # Check if category severities are configured
104
+ category_severities = config.config.get("category_severities", {})
105
+ if not category_severities:
106
+ return self.get_severity(config)
107
+
108
+ # Get the category for this action
109
+ category = get_category_for_action(action)
110
+ if category and category in category_severities:
111
+ return category_severities[category]
112
+
113
+ # Fall back to default severity
114
+ return self.get_severity(config)
115
+
116
+ def _get_actions_covered_by_condition_enforcement(self, config: CheckConfig) -> set[str]:
117
+ """
118
+ Get set of actions that are covered by action_condition_enforcement requirements.
119
+
120
+ This prevents duplicate warnings when an action is already validated by
121
+ formal condition requirements.
122
+
123
+ Args:
124
+ config: Check configuration with root_config access
125
+
126
+ Returns:
127
+ Set of action strings that are covered by condition requirements
128
+ """
129
+ covered_actions: set[str] = set()
130
+
131
+ # Access action_condition_enforcement config from root_config
132
+ ace_config = config.root_config.get("action_condition_enforcement", {})
133
+ requirements = ace_config.get("requirements", [])
134
+
135
+ for requirement in requirements:
136
+ # Get actions from requirement
137
+ actions_config = requirement.get("actions", [])
138
+ if isinstance(actions_config, list):
139
+ covered_actions.update(actions_config)
140
+
141
+ return covered_actions
142
+
143
+ def _get_category_specific_suggestion(
144
+ self, action: str, config: CheckConfig
145
+ ) -> tuple[str, str]:
146
+ """
147
+ Get category-specific suggestion and example for an action using two-tier lookup.
148
+
149
+ This method provides suggestions for the sensitive_action check, which flags
150
+ actions that have NO conditions. It does NOT validate specific conditions
151
+ (that's handled by the action_condition_enforcement check).
152
+
153
+ Tier 1: Check action_overrides in category suggestions for important actions
154
+ Tier 2: Fall back to category-level default suggestions
155
+
156
+ Args:
157
+ action: The AWS action to check
158
+ config: Check configuration
159
+
160
+ Returns:
161
+ Tuple of (suggestion_text, example_text) tailored to the action's category
162
+ """
163
+ # TIER 1: Check action-specific overrides in category suggestions
164
+ category = get_category_for_action(action)
165
+ category_suggestions = config.config.get("category_suggestions", {})
166
+
167
+ if category and category in category_suggestions:
168
+ category_data = category_suggestions[category]
169
+
170
+ # Check if there's an action-specific override
171
+ action_overrides = category_data.get("action_overrides", {})
172
+ if action in action_overrides:
173
+ override = action_overrides[action]
174
+ return (override["suggestion"], override["example"])
175
+
176
+ # TIER 2: Fall back to category-level defaults
177
+ return (category_data["suggestion"], category_data["example"])
178
+
179
+ # Ultimate fallback: Generic ABAC guidance for uncategorized actions
180
+ return (
181
+ "Add IAM conditions to limit when this action can be used. Use ABAC for scalability:\n"
182
+ "• Match principal tags to resource tags (`aws:PrincipalTag/<tag-name>` = `aws:ResourceTag/<tag-name>`)\n"
183
+ "• Match organization principal tags to resource tags (`aws:PrincipalOrgID` = `aws:ResourceOrgID`)\n"
184
+ "• Require MFA (`aws:MultiFactorAuthPresent` = `true`)\n"
185
+ "• Restrict by IP (`aws:SourceIp`) or VPC (`aws:SourceVpc`)",
186
+ '"Condition": {\n'
187
+ ' "StringEquals": {\n'
188
+ ' "aws:PrincipalTag/owner": "${aws:ResourceTag/owner}"\n'
189
+ " }\n"
190
+ "}",
191
+ )
192
+
193
+ async def execute(
194
+ self,
195
+ statement: Statement,
196
+ statement_idx: int,
197
+ fetcher: AWSServiceFetcher,
198
+ config: CheckConfig,
199
+ ) -> list[ValidationIssue]:
200
+ """Execute sensitive action check on a statement."""
201
+ issues = []
202
+
203
+ # Only check Allow statements
204
+ if statement.effect != "Allow":
205
+ return issues
206
+
207
+ actions = statement.get_actions()
208
+ has_conditions = statement.condition is not None and len(statement.condition) > 0
209
+
210
+ # Expand wildcards to actual actions using AWS API
211
+ expanded_actions = await expand_wildcard_actions(actions, fetcher)
212
+
213
+ # Check if sensitive actions match using any_of/all_of logic
214
+ is_sensitive, matched_actions = check_sensitive_actions(
215
+ expanded_actions, config, DEFAULT_SENSITIVE_ACTIONS
216
+ )
217
+
218
+ if is_sensitive and not has_conditions:
219
+ # Filter out actions already covered by action_condition_enforcement
220
+ # This prevents duplicate warnings with different messages
221
+ covered_actions = self._get_actions_covered_by_condition_enforcement(config)
222
+ matched_actions = [
223
+ action for action in matched_actions if action not in covered_actions
224
+ ]
225
+
226
+ # If all matched actions are covered elsewhere, skip this check
227
+ if not matched_actions:
228
+ return issues
229
+ # Create appropriate message based on matched actions using configurable templates
230
+ if len(matched_actions) == 1:
231
+ message_template = config.config.get(
232
+ "message_single",
233
+ "Sensitive action `{action}` should have conditions to limit when it can be used",
234
+ )
235
+ message = message_template.format(action=matched_actions[0])
236
+ else:
237
+ action_list = "', '".join(matched_actions)
238
+ message_template = config.config.get(
239
+ "message_multiple",
240
+ "Sensitive actions `{actions}` should have conditions to limit when they can be used",
241
+ )
242
+ message = message_template.format(actions=action_list)
243
+
244
+ # Get category-specific suggestion and example (or use config defaults)
245
+ # Use the first matched action to determine the category
246
+ suggestion_text, example = self._get_category_specific_suggestion(
247
+ matched_actions[0], config
248
+ )
249
+
250
+ # Determine severity based on the highest severity action in the list
251
+ # If single action, use its category severity
252
+ # If multiple actions, use the highest severity among them
253
+ severity = self.get_severity(config) # Default
254
+ if matched_actions:
255
+ # Get severity for first action (or highest if we want to be more sophisticated)
256
+ severity = self._get_severity_for_action(matched_actions[0], config)
257
+
258
+ issues.append(
259
+ ValidationIssue(
260
+ severity=severity,
261
+ statement_sid=statement.sid,
262
+ statement_index=statement_idx,
263
+ issue_type="missing_condition",
264
+ message=message,
265
+ action=(matched_actions[0] if len(matched_actions) == 1 else None),
266
+ suggestion=suggestion_text,
267
+ example=example if example else None,
268
+ line_number=statement.line_number,
269
+ field_name="action",
270
+ )
271
+ )
272
+
273
+ return issues
274
+
275
+ def _apply_merge_strategy(
276
+ self,
277
+ merge_strategy: str,
278
+ user_config: list[dict] | None,
279
+ default_config: list[dict] | None,
280
+ ) -> list[dict] | None:
281
+ """
282
+ Apply merge strategy to combine user and default sensitive action patterns.
283
+
284
+ Args:
285
+ merge_strategy: One of "per_action_override", "user_only", "append",
286
+ "replace_all", or "defaults_only"
287
+ user_config: User-provided sensitive action patterns (or None)
288
+ default_config: Default sensitive action patterns (or None)
289
+
290
+ Returns:
291
+ Merged list of patterns based on strategy, or None if no patterns
292
+ """
293
+ if merge_strategy == "user_only":
294
+ # Use ONLY user patterns, completely ignore defaults
295
+ return user_config
296
+
297
+ elif merge_strategy == "defaults_only":
298
+ # Use ONLY defaults, ignore user patterns
299
+ return default_config
300
+
301
+ elif merge_strategy == "append":
302
+ # Combine both (defaults first, then user)
303
+ result = []
304
+ if default_config:
305
+ result.extend(default_config)
306
+ if user_config:
307
+ result.extend(user_config)
308
+ return result if result else None
309
+
310
+ elif merge_strategy == "replace_all":
311
+ # User replaces all if provided, otherwise use defaults
312
+ return user_config if user_config else default_config
313
+
314
+ else: # "per_action_override" (default)
315
+ # If user provides patterns, use them; otherwise use defaults
316
+ # This is the legacy behavior
317
+ return user_config if user_config else default_config
318
+
319
+ async def execute_policy(
320
+ self,
321
+ policy: "IAMPolicy",
322
+ policy_file: str,
323
+ fetcher: AWSServiceFetcher,
324
+ config: CheckConfig,
325
+ **kwargs,
326
+ ) -> list[ValidationIssue]:
327
+ """
328
+ Execute policy-level sensitive action checks.
329
+
330
+ This method examines the entire policy to detect privilege escalation patterns
331
+ and other security issues that span multiple statements.
332
+
333
+ Args:
334
+ policy: The complete IAM policy to check
335
+ policy_file: Path to the policy file (for context/reporting)
336
+ fetcher: AWS service fetcher for validation against AWS APIs
337
+ config: Configuration for this check instance
338
+
339
+ Returns:
340
+ List of ValidationIssue objects found by this check
341
+ """
342
+ del policy_file, fetcher # Not used in current implementation
343
+ issues = []
344
+
345
+ # Handle policies with no statements
346
+ if not policy.statement:
347
+ return []
348
+
349
+ # Collect all actions from all Allow statements across the entire policy
350
+ all_actions: set[str] = set()
351
+ statement_map: dict[
352
+ str, list[tuple[int, str | None]]
353
+ ] = {} # action -> [(stmt_idx, sid), ...]
354
+
355
+ for idx, statement in enumerate(policy.statement):
356
+ if statement.effect == "Allow":
357
+ actions = statement.get_actions()
358
+ # Filter out wildcards for privilege escalation detection
359
+ filtered_actions = [a for a in actions if a != "*"]
360
+
361
+ for action in filtered_actions:
362
+ all_actions.add(action)
363
+ if action not in statement_map:
364
+ statement_map[action] = []
365
+ statement_map[action].append((idx, statement.sid))
366
+
367
+ # Get configuration for sensitive actions with merge_strategy support
368
+ # merge_strategy options:
369
+ # - "append": Add user patterns ON TOP OF defaults (both apply) - DEFAULT
370
+ # - "user_only": Use ONLY user patterns, disable ALL default privilege escalation patterns
371
+ # - "defaults_only": Ignore user patterns, use only defaults
372
+ # - "replace_all": User patterns completely replace ALL defaults (if provided)
373
+ # - "per_action_override": User patterns replace defaults for matching action combos
374
+ merge_strategy = config.config.get("merge_strategy", "append")
375
+
376
+ # Determine which sensitive_actions patterns to use based on merge_strategy
377
+ # Note: The config.config already contains deep-merged values from defaults + user config
378
+ # For lists like sensitive_actions, user config REPLACES defaults (not merges)
379
+ # So if user provided sensitive_actions, it's already the only value in config.config
380
+ sensitive_actions_config: list[dict] | None = None
381
+ sensitive_patterns_config: list[dict] | None = None
382
+
383
+ if merge_strategy == "user_only":
384
+ # user_only: Disable ALL default patterns
385
+ # If user set merge_strategy: "user_only", they want NO defaults
386
+ # They must explicitly provide sensitive_actions if they want any checks
387
+ # Since we can't distinguish user-provided from defaults after merge,
388
+ # we assume user_only means "no patterns" unless user explicitly provided them
389
+ # (which would have replaced defaults anyway)
390
+ sensitive_actions_config = None
391
+ sensitive_patterns_config = None
392
+
393
+ elif merge_strategy == "defaults_only":
394
+ # Use only defaults - but since config is merged, we use what's there
395
+ # (user would need to NOT provide sensitive_actions to get defaults)
396
+ sensitive_actions_config = config.config.get("sensitive_actions")
397
+ sensitive_patterns_config = config.config.get("sensitive_action_patterns")
398
+
399
+ else:
400
+ # append, replace_all, per_action_override all use the merged config
401
+ # The deep_merge already handled the merging:
402
+ # - If user provided sensitive_actions, it replaced defaults
403
+ # - If user didn't provide it, defaults are in config
404
+ sensitive_actions_config = config.config.get("sensitive_actions")
405
+ sensitive_patterns_config = config.config.get("sensitive_action_patterns")
406
+
407
+ # Check for privilege escalation patterns using all_of logic
408
+ # We need to check both exact actions and patterns
409
+ policy_issues = []
410
+
411
+ # Check sensitive_actions configuration
412
+ if sensitive_actions_config:
413
+ policy_issues.extend(
414
+ check_policy_level_actions(
415
+ list(all_actions),
416
+ statement_map,
417
+ sensitive_actions_config,
418
+ config,
419
+ "actions",
420
+ self.get_severity,
421
+ )
422
+ )
423
+
424
+ # Check sensitive_action_patterns configuration
425
+ if sensitive_patterns_config:
426
+ policy_issues.extend(
427
+ check_policy_level_actions(
428
+ list(all_actions),
429
+ statement_map,
430
+ sensitive_patterns_config,
431
+ config,
432
+ "patterns",
433
+ self.get_severity,
434
+ )
435
+ )
436
+
437
+ issues.extend(policy_issues)
438
+ return issues
@@ -0,0 +1,98 @@
1
+ """Service wildcard check - detects service-level wildcards like 'iam:*', 's3:*'."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from iam_validator.checks.utils.action_parser import parse_action
6
+ from iam_validator.core.aws_service import AWSServiceFetcher
7
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
8
+ from iam_validator.core.models import Statement, ValidationIssue
9
+
10
+
11
+ class ServiceWildcardCheck(PolicyCheck):
12
+ """Checks for service-level wildcards (e.g., 'iam:*', 's3:*') which grant all permissions for a service."""
13
+
14
+ check_id: ClassVar[str] = "service_wildcard"
15
+ description: ClassVar[str] = "Checks for service-level wildcards (e.g., 'iam:*', 's3:*')"
16
+ default_severity: ClassVar[str] = "high"
17
+
18
+ async def execute(
19
+ self,
20
+ statement: Statement,
21
+ statement_idx: int,
22
+ fetcher: AWSServiceFetcher,
23
+ config: CheckConfig,
24
+ ) -> list[ValidationIssue]:
25
+ """Execute service wildcard check on a statement."""
26
+ issues = []
27
+
28
+ # Only check Allow statements
29
+ if statement.effect != "Allow":
30
+ return issues
31
+
32
+ actions = statement.get_actions()
33
+ allowed_services = self._get_allowed_service_wildcards(config)
34
+
35
+ for action in actions:
36
+ # Skip full wildcard (covered by wildcard_action check)
37
+ if action == "*":
38
+ continue
39
+
40
+ # Parse action and check if it's a service-level wildcard (e.g., "iam:*", "s3:*")
41
+ parsed = parse_action(action)
42
+ if parsed and parsed.action_name == "*":
43
+ service = parsed.service
44
+
45
+ # Check if this service is in the allowed list
46
+ if service not in allowed_services:
47
+ # Get message template and replace placeholders
48
+ message_template = config.config.get(
49
+ "message",
50
+ "Service-level wildcard `{action}` grants all permissions for `{service}` service",
51
+ )
52
+ suggestion_template = config.config.get(
53
+ "suggestion",
54
+ "Consider specifying explicit actions instead of `{action}`. If you need multiple actions, list them individually or use more specific wildcards like `{service}:Get*` or `{service}:List*`.",
55
+ )
56
+ example_template = config.config.get("example", "")
57
+
58
+ message = message_template.format(action=action, service=service)
59
+ suggestion = suggestion_template.format(action=action, service=service)
60
+ example = (
61
+ example_template.format(action=action, service=service)
62
+ if example_template
63
+ else ""
64
+ )
65
+
66
+ issues.append(
67
+ ValidationIssue(
68
+ severity=self.get_severity(config),
69
+ statement_sid=statement.sid,
70
+ statement_index=statement_idx,
71
+ issue_type="overly_permissive",
72
+ message=message,
73
+ action=action,
74
+ suggestion=suggestion,
75
+ example=example if example else None,
76
+ line_number=statement.line_number,
77
+ field_name="action",
78
+ )
79
+ )
80
+
81
+ return issues
82
+
83
+ def _get_allowed_service_wildcards(self, config: CheckConfig) -> set[str]:
84
+ """
85
+ Get list of services that are allowed to use service-level wildcards.
86
+
87
+ This allows configuration like:
88
+ service_wildcard:
89
+ allowed_services:
90
+ - "logs" # Allow "logs:*"
91
+ - "cloudwatch" # Allow "cloudwatch:*"
92
+
93
+ Returns empty set if no exceptions are configured.
94
+ """
95
+ allowed = config.config.get("allowed_services", [])
96
+ if allowed and isinstance(allowed, list):
97
+ return set(allowed)
98
+ return set()