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,170 @@
1
+ """Statement ID (SID) uniqueness and format check.
2
+
3
+ This check validates that Statement IDs (Sids):
4
+ 1. Are unique within a policy
5
+ 2. Follow AWS naming requirements (alphanumeric, hyphens, underscores only - no spaces)
6
+
7
+ According to AWS best practices, while not strictly required, having unique SIDs
8
+ makes it easier to reference specific statements and improves policy maintainability.
9
+
10
+ This is implemented as a policy-level check that runs once when processing the first
11
+ statement, examining all statements in the policy to find duplicates and format issues.
12
+ """
13
+
14
+ import re
15
+ from collections import Counter
16
+
17
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
18
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
19
+ from iam_validator.core.models import IAMPolicy, Statement, ValidationIssue
20
+
21
+
22
+ def _check_sid_uniqueness_impl(policy: IAMPolicy, severity: str) -> list[ValidationIssue]:
23
+ """Implementation of SID uniqueness and format checking.
24
+
25
+ Args:
26
+ policy: IAM policy to validate
27
+ severity: Severity level for issues found
28
+
29
+ Returns:
30
+ List of ValidationIssue objects for duplicate or invalid SIDs
31
+ """
32
+ issues: list[ValidationIssue] = []
33
+
34
+ # AWS SID requirements: alphanumeric characters, hyphens, and underscores only
35
+ # No spaces allowed
36
+ sid_pattern = re.compile(r"^[a-zA-Z0-9_-]+$")
37
+
38
+ # Collect all SIDs (ignoring None/empty values) and check format
39
+ sids_with_indices: list[tuple[str, int]] = []
40
+ for idx, statement in enumerate(policy.statement):
41
+ if statement.sid: # Only check statements that have a SID
42
+ # Check SID format
43
+ if not sid_pattern.match(statement.sid):
44
+ # Identify the issue
45
+ if " " in statement.sid:
46
+ issue_msg = f"Statement ID '{statement.sid}' contains spaces, which are not allowed by AWS"
47
+ suggestion = (
48
+ f"Remove spaces from the SID. Example: '{statement.sid.replace(' ', '')}'"
49
+ )
50
+ else:
51
+ invalid_chars = "".join(
52
+ set(c for c in statement.sid if not c.isalnum() and c not in "_-")
53
+ )
54
+ issue_msg = f"Statement ID '{statement.sid}' contains invalid characters: {invalid_chars}"
55
+ suggestion = (
56
+ "SIDs must contain only alphanumeric characters, hyphens, and underscores"
57
+ )
58
+
59
+ issues.append(
60
+ ValidationIssue(
61
+ severity="error", # Invalid SID format is an error
62
+ statement_sid=statement.sid,
63
+ statement_index=idx,
64
+ issue_type="invalid_sid_format",
65
+ message=issue_msg,
66
+ suggestion=suggestion,
67
+ line_number=statement.line_number,
68
+ )
69
+ )
70
+
71
+ sids_with_indices.append((statement.sid, idx))
72
+
73
+ # Find duplicates
74
+ sid_counts = Counter(sid for sid, _ in sids_with_indices)
75
+ duplicate_sids = {sid: count for sid, count in sid_counts.items() if count > 1}
76
+
77
+ # Create issues for each duplicate SID
78
+ for duplicate_sid, count in duplicate_sids.items():
79
+ # Find all statement indices with this SID
80
+ indices = [idx for sid, idx in sids_with_indices if sid == duplicate_sid]
81
+
82
+ # Create an issue for each occurrence except the first
83
+ # (the first occurrence is "original", subsequent ones are "duplicates")
84
+ for idx in indices[1:]:
85
+ statement = policy.statement[idx]
86
+ # Convert to 1-indexed statement numbers for user-facing message
87
+ statement_numbers = ", ".join(f"#{i + 1}" for i in indices)
88
+ issues.append(
89
+ ValidationIssue(
90
+ severity=severity,
91
+ statement_sid=duplicate_sid,
92
+ statement_index=idx,
93
+ issue_type="duplicate_sid",
94
+ message=f"Statement ID '{duplicate_sid}' is used {count} times in this policy (found in statements {statement_numbers})",
95
+ suggestion="Change this SID to a unique value. Statement IDs help identify and reference specific statements, so duplicates can cause confusion.",
96
+ line_number=statement.line_number,
97
+ )
98
+ )
99
+
100
+ return issues
101
+
102
+
103
+ class SidUniquenessCheck(PolicyCheck):
104
+ """Validates that Statement IDs (Sids) are unique within a policy.
105
+
106
+ This is a special policy-level check that examines all statements together.
107
+ It only runs once when processing the first statement to avoid duplicate work.
108
+ """
109
+
110
+ @property
111
+ def check_id(self) -> str:
112
+ return "sid_uniqueness"
113
+
114
+ @property
115
+ def description(self) -> str:
116
+ return "Validates that Statement IDs (Sids) are unique and follow AWS naming requirements (no spaces)"
117
+
118
+ @property
119
+ def default_severity(self) -> str:
120
+ return "warning"
121
+
122
+ async def execute(
123
+ self,
124
+ statement: Statement,
125
+ statement_idx: int,
126
+ fetcher: AWSServiceFetcher,
127
+ config: CheckConfig,
128
+ ) -> list[ValidationIssue]:
129
+ """Execute the SID uniqueness check at statement level.
130
+
131
+ This is a policy-level check, so statement-level execution returns empty.
132
+ The actual check runs in execute_policy() which has access to all statements.
133
+
134
+ Args:
135
+ statement: The IAM policy statement (unused)
136
+ statement_idx: Index of the statement in the policy (unused)
137
+ fetcher: AWS service fetcher (unused for this check)
138
+ config: Configuration for this check instance (unused)
139
+
140
+ Returns:
141
+ Empty list (actual check runs in execute_policy())
142
+ """
143
+ del statement, statement_idx, fetcher, config # Unused
144
+ # This is a policy-level check - execution happens in execute_policy()
145
+ return []
146
+
147
+ async def execute_policy(
148
+ self,
149
+ policy: IAMPolicy,
150
+ policy_file: str,
151
+ fetcher: AWSServiceFetcher,
152
+ config: CheckConfig,
153
+ **kwargs,
154
+ ) -> list[ValidationIssue]:
155
+ """Execute the SID uniqueness check on the entire policy.
156
+
157
+ This method examines all statements together to find duplicate SIDs.
158
+
159
+ Args:
160
+ policy: The complete IAM policy to validate
161
+ policy_file: Path to the policy file (unused, kept for API consistency)
162
+ fetcher: AWS service fetcher (unused for this check)
163
+ config: Configuration for this check instance
164
+
165
+ Returns:
166
+ List of ValidationIssue objects for duplicate SIDs
167
+ """
168
+ del policy_file, fetcher # Unused
169
+ severity = self.get_severity(config)
170
+ return _check_sid_uniqueness_impl(policy, severity)
@@ -0,0 +1 @@
1
+ """Utility modules for IAM policy checks."""
@@ -0,0 +1,143 @@
1
+ """Policy-level privilege escalation detection for IAM policy checks.
2
+
3
+ This module provides functionality to detect privilege escalation patterns
4
+ that span multiple statements in a policy.
5
+ """
6
+
7
+ import re
8
+
9
+ from iam_validator.core.check_registry import CheckConfig
10
+ from iam_validator.core.models import ValidationIssue
11
+
12
+
13
+ def check_policy_level_actions(
14
+ all_actions: list[str],
15
+ statement_map: dict[str, list[tuple[int, str | None]]],
16
+ config,
17
+ check_config: CheckConfig,
18
+ check_type: str,
19
+ get_severity_func,
20
+ ) -> list[ValidationIssue]:
21
+ """
22
+ Check for policy-level privilege escalation patterns.
23
+
24
+ This function detects when a policy grants a dangerous combination of
25
+ permissions across multiple statements (e.g., iam:CreateUser + iam:AttachUserPolicy).
26
+
27
+ Args:
28
+ all_actions: All actions across the entire policy
29
+ statement_map: Mapping of action -> [(statement_idx, sid), ...]
30
+ config: The sensitive_actions or sensitive_action_patterns configuration
31
+ check_config: Full check configuration
32
+ check_type: Either "actions" (exact match) or "patterns" (regex match)
33
+ get_severity_func: Function to get severity for the check
34
+
35
+ Returns:
36
+ List of ValidationIssue objects
37
+ """
38
+ issues = []
39
+
40
+ if not config:
41
+ return issues
42
+
43
+ # Handle list of items (could be simple strings or dicts with all_of/any_of)
44
+ if isinstance(config, list):
45
+ for item in config:
46
+ if isinstance(item, dict) and "all_of" in item:
47
+ # This is a privilege escalation pattern - all actions must be present
48
+ issue = _check_all_of_pattern(
49
+ all_actions,
50
+ statement_map,
51
+ item["all_of"],
52
+ check_config,
53
+ check_type,
54
+ get_severity_func,
55
+ )
56
+ if issue:
57
+ issues.append(issue)
58
+
59
+ # Handle dict with all_of at the top level
60
+ elif isinstance(config, dict) and "all_of" in config:
61
+ issue = _check_all_of_pattern(
62
+ all_actions,
63
+ statement_map,
64
+ config["all_of"],
65
+ check_config,
66
+ check_type,
67
+ get_severity_func,
68
+ )
69
+ if issue:
70
+ issues.append(issue)
71
+
72
+ return issues
73
+
74
+
75
+ def _check_all_of_pattern(
76
+ all_actions: list[str],
77
+ statement_map: dict[str, list[tuple[int, str | None]]],
78
+ required_actions: list[str],
79
+ check_config: CheckConfig,
80
+ check_type: str,
81
+ get_severity_func,
82
+ ) -> ValidationIssue | None:
83
+ """
84
+ Check if all required actions/patterns are present in the policy.
85
+
86
+ Args:
87
+ all_actions: All actions across the entire policy
88
+ statement_map: Mapping of action -> [(statement_idx, sid), ...]
89
+ required_actions: List of required actions or patterns
90
+ check_config: Full check configuration
91
+ check_type: Either "actions" (exact match) or "patterns" (regex match)
92
+ get_severity_func: Function to get severity for the check
93
+
94
+ Returns:
95
+ ValidationIssue if privilege escalation detected, None otherwise
96
+ """
97
+ matched_actions = []
98
+
99
+ if check_type == "actions":
100
+ # Exact matching
101
+ matched_actions = [a for a in all_actions if a in required_actions]
102
+ else:
103
+ # Pattern matching - for each pattern, find actions that match
104
+ for pattern in required_actions:
105
+ for action in all_actions:
106
+ try:
107
+ if re.match(pattern, action):
108
+ matched_actions.append(action)
109
+ break # Found at least one match for this pattern
110
+ except re.error:
111
+ continue
112
+
113
+ # Check if ALL required actions/patterns are present
114
+ if len(matched_actions) >= len(required_actions):
115
+ # Privilege escalation detected!
116
+ severity = get_severity_func(check_config, "sensitive_action_check", "error")
117
+
118
+ # Collect which statements these actions appear in
119
+ statement_refs = []
120
+ for action in matched_actions:
121
+ if action in statement_map:
122
+ for stmt_idx, sid in statement_map[action]:
123
+ sid_str = f"'{sid}'" if sid else f"#{stmt_idx}"
124
+ statement_refs.append(f"Statement {sid_str}: {action}")
125
+
126
+ action_list = "', '".join(matched_actions)
127
+ stmt_details = "\n - ".join(statement_refs)
128
+
129
+ return ValidationIssue(
130
+ severity=severity,
131
+ statement_sid=None, # Policy-level issue
132
+ statement_index=-1, # -1 indicates policy-level issue
133
+ issue_type="privilege_escalation",
134
+ message=f"Policy-level privilege escalation detected: grants all of ['{action_list}'] across multiple statements",
135
+ suggestion=f"These actions combined allow privilege escalation. Consider:\n"
136
+ f" 1. Splitting into separate policies for different users/roles\n"
137
+ f" 2. Adding strict conditions to limit when these actions can be used together\n"
138
+ f" 3. Reviewing if all these permissions are truly necessary\n\n"
139
+ f"Actions found in:\n - {stmt_details}",
140
+ line_number=None,
141
+ )
142
+
143
+ return None
@@ -0,0 +1,294 @@
1
+ """Sensitive action matching utilities for IAM policy checks.
2
+
3
+ This module provides functionality to match actions against sensitive action
4
+ configurations, supporting exact matches, regex patterns, and any_of/all_of logic.
5
+
6
+ Performance optimizations:
7
+ - Uses frozenset for O(1) lookups
8
+ - LRU cache for compiled regex patterns
9
+ - Lazy loading of default actions from modular data structure
10
+ """
11
+
12
+ import re
13
+ from functools import lru_cache
14
+ from re import Pattern
15
+
16
+ from iam_validator.core.check_registry import CheckConfig
17
+ from iam_validator.core.config.sensitive_actions import get_sensitive_actions
18
+
19
+ # Lazy-loaded default set of sensitive actions
20
+ # This will be loaded only when first accessed
21
+ _DEFAULT_SENSITIVE_ACTIONS_CACHE: frozenset[str] | None = None
22
+
23
+
24
+ def _get_default_sensitive_actions() -> frozenset[str]:
25
+ """
26
+ Get default sensitive actions with lazy loading and caching.
27
+
28
+ Returns:
29
+ Frozenset of all default sensitive actions
30
+
31
+ Performance:
32
+ - First call: Loads from sensitive actions list
33
+ - Subsequent calls: O(1) cached lookup
34
+ """
35
+ global _DEFAULT_SENSITIVE_ACTIONS_CACHE
36
+ if _DEFAULT_SENSITIVE_ACTIONS_CACHE is None:
37
+ _DEFAULT_SENSITIVE_ACTIONS_CACHE = get_sensitive_actions()
38
+ return _DEFAULT_SENSITIVE_ACTIONS_CACHE
39
+
40
+
41
+ def get_sensitive_actions_by_categories(categories: list[str] | None = None) -> frozenset[str]:
42
+ """
43
+ Get sensitive actions filtered by categories.
44
+
45
+ Args:
46
+ categories: List of category IDs to include. If None, returns all actions.
47
+ Valid categories: 'credential_exposure', 'data_access',
48
+ 'priv_esc', 'resource_exposure'
49
+
50
+ Returns:
51
+ Frozenset of sensitive actions matching the specified categories
52
+
53
+ Examples:
54
+ >>> # Get all sensitive actions (default behavior)
55
+ >>> all_actions = get_sensitive_actions_by_categories()
56
+
57
+ >>> # Get only privilege escalation actions
58
+ >>> priv_esc = get_sensitive_actions_by_categories(['priv_esc'])
59
+
60
+ >>> # Get credential exposure and data access actions
61
+ >>> sensitive = get_sensitive_actions_by_categories(['credential_exposure', 'data_access'])
62
+ """
63
+ return get_sensitive_actions(categories)
64
+
65
+
66
+ # Export for backward compatibility
67
+ DEFAULT_SENSITIVE_ACTIONS = _get_default_sensitive_actions()
68
+
69
+
70
+ # Global regex pattern cache for performance
71
+ @lru_cache(maxsize=256)
72
+ def compile_pattern(pattern: str) -> Pattern[str] | None:
73
+ """Compile and cache regex patterns.
74
+
75
+ Args:
76
+ pattern: Regex pattern string
77
+
78
+ Returns:
79
+ Compiled pattern or None if invalid
80
+ """
81
+ try:
82
+ return re.compile(pattern)
83
+ except re.error:
84
+ return None
85
+
86
+
87
+ def check_sensitive_actions(
88
+ actions: list[str], config: CheckConfig, default_actions: frozenset[str] | None = None
89
+ ) -> tuple[bool, list[str]]:
90
+ """
91
+ Check if actions match sensitive action criteria with any_of/all_of support.
92
+
93
+ Args:
94
+ actions: List of actions to check
95
+ config: Check configuration
96
+ default_actions: Default sensitive actions to use if no config (lazy-loaded)
97
+
98
+ Returns:
99
+ tuple[bool, list[str]]: (is_sensitive, matched_actions)
100
+ - is_sensitive: True if the actions match the sensitive criteria
101
+ - matched_actions: List of actions that matched the criteria
102
+
103
+ Performance:
104
+ - Uses lazy-loaded defaults (only loaded on first use)
105
+ - O(1) frozenset lookups for action matching
106
+ """
107
+ # Check if categories are specified in config
108
+ categories = config.config.get("categories")
109
+ if categories is not None:
110
+ # If categories is an empty list, disable the check
111
+ if len(categories) == 0:
112
+ return False, []
113
+ # Get sensitive actions filtered by categories
114
+ default_actions = get_sensitive_actions_by_categories(categories)
115
+ elif default_actions is None:
116
+ # Use all categories if no specific categories configured
117
+ default_actions = _get_default_sensitive_actions()
118
+
119
+ # Filter out wildcards
120
+ filtered_actions = [a for a in actions if a != "*"]
121
+ if not filtered_actions:
122
+ return False, []
123
+
124
+ # Get configuration for both sensitive_actions and sensitive_action_patterns
125
+ # Config is now flat (no longer nested under sensitive_action_check)
126
+ sensitive_actions_config = config.config.get("sensitive_actions")
127
+ sensitive_patterns_config = config.config.get("sensitive_action_patterns")
128
+
129
+ # Check sensitive_actions (exact matches)
130
+ actions_match, actions_matched = check_actions_config(
131
+ filtered_actions, sensitive_actions_config, default_actions
132
+ )
133
+
134
+ # Check sensitive_action_patterns (regex patterns)
135
+ patterns_match, patterns_matched = check_patterns_config(
136
+ filtered_actions, sensitive_patterns_config
137
+ )
138
+
139
+ # Combine results - if either matched, we consider it sensitive
140
+ is_sensitive = actions_match or patterns_match
141
+ # Use set operations for efficient deduplication
142
+ matched_set = set(actions_matched) | set(patterns_matched)
143
+ matched_actions = list(matched_set)
144
+
145
+ return is_sensitive, matched_actions
146
+
147
+
148
+ def check_actions_config(
149
+ actions: list[str], config, default_actions: frozenset[str]
150
+ ) -> tuple[bool, list[str]]:
151
+ """
152
+ Check actions against sensitive_actions configuration.
153
+
154
+ Supports:
155
+ - Simple list: ["action1", "action2"] (backward compatible, any_of logic)
156
+ - any_of: {"any_of": ["action1", "action2"]}
157
+ - all_of: {"all_of": ["action1", "action2"]}
158
+ - Multiple groups: [{"all_of": [...]}, {"all_of": [...]}, "action3"]
159
+
160
+ Args:
161
+ actions: List of actions to check
162
+ config: Sensitive actions configuration
163
+ default_actions: Default sensitive actions to use if no config
164
+
165
+ Returns:
166
+ tuple[bool, list[str]]: (matches, matched_actions)
167
+ """
168
+ if not config:
169
+ # If no config, fall back to defaults with any_of logic
170
+ # default_actions is already a frozenset for O(1) lookups
171
+ matched = [a for a in actions if a in default_actions]
172
+ return len(matched) > 0, matched
173
+
174
+ # Handle simple list with potential mixed items
175
+ if isinstance(config, list):
176
+ # Use set for O(1) membership checks
177
+ all_matched = set()
178
+ actions_set = set(actions) # Convert once for O(1) lookups
179
+
180
+ for item in config:
181
+ # Each item can be a string, or a dict with any_of/all_of
182
+ if isinstance(item, str):
183
+ # Simple string - check if action matches (O(1) lookup)
184
+ if item in actions_set:
185
+ all_matched.add(item)
186
+ elif isinstance(item, dict):
187
+ # Recurse for dict items
188
+ matches, matched = check_actions_config(actions, item, default_actions)
189
+ if matches:
190
+ all_matched.update(matched)
191
+
192
+ return len(all_matched) > 0, list(all_matched)
193
+
194
+ # Handle dict with any_of/all_of
195
+ if isinstance(config, dict):
196
+ # any_of: at least one action must match
197
+ if "any_of" in config:
198
+ # Convert once for O(1) intersection
199
+ any_of_set = set(config["any_of"])
200
+ actions_set = set(actions)
201
+ matched = list(any_of_set & actions_set)
202
+ return len(matched) > 0, matched
203
+
204
+ # all_of: all specified actions must be present in the statement
205
+ if "all_of" in config:
206
+ all_of_set = set(config["all_of"])
207
+ actions_set = set(actions)
208
+ matched = list(all_of_set & actions_set)
209
+ # All required actions must be present
210
+ return all_of_set.issubset(actions_set), matched
211
+
212
+ return False, []
213
+
214
+
215
+ def check_patterns_config(actions: list[str], config) -> tuple[bool, list[str]]:
216
+ """
217
+ Check actions against sensitive_action_patterns configuration.
218
+
219
+ Supports:
220
+ - Simple list: ["^pattern1.*", "^pattern2.*"] (backward compatible, any_of logic)
221
+ - any_of: {"any_of": ["^pattern1.*", "^pattern2.*"]}
222
+ - all_of: {"all_of": ["^pattern1.*", "^pattern2.*"]}
223
+ - Multiple groups: [{"all_of": [...]}, {"any_of": [...]}, "^pattern.*"]
224
+
225
+ Args:
226
+ actions: List of actions to check
227
+ config: Sensitive action patterns configuration
228
+
229
+ Returns:
230
+ tuple[bool, list[str]]: (matches, matched_actions)
231
+
232
+ Performance:
233
+ Uses cached compiled regex patterns for 10-50x speedup
234
+ """
235
+ if not config:
236
+ return False, []
237
+
238
+ # Handle simple list with potential mixed items
239
+ if isinstance(config, list):
240
+ # Use set for O(1) membership checks instead of list
241
+ all_matched = set()
242
+
243
+ for item in config:
244
+ # Each item can be a string pattern, or a dict with any_of/all_of
245
+ if isinstance(item, str):
246
+ # Simple string pattern - check if any action matches
247
+ # Use cached compiled pattern
248
+ compiled = compile_pattern(item)
249
+ if compiled:
250
+ for action in actions:
251
+ if compiled.match(action):
252
+ all_matched.add(action)
253
+ elif isinstance(item, dict):
254
+ # Recurse for dict items
255
+ matches, matched = check_patterns_config(actions, item)
256
+ if matches:
257
+ all_matched.update(matched)
258
+
259
+ return len(all_matched) > 0, list(all_matched)
260
+
261
+ # Handle dict with any_of/all_of
262
+ if isinstance(config, dict):
263
+ # any_of: at least one action must match at least one pattern
264
+ if "any_of" in config:
265
+ matched = set()
266
+ # Pre-compile all patterns
267
+ compiled_patterns = [compile_pattern(p) for p in config["any_of"]]
268
+
269
+ for action in actions:
270
+ for compiled in compiled_patterns:
271
+ if compiled and compiled.match(action):
272
+ matched.add(action)
273
+ break
274
+ return len(matched) > 0, list(matched)
275
+
276
+ # all_of: at least one action must match ALL patterns
277
+ if "all_of" in config:
278
+ # Pre-compile all patterns
279
+ compiled_patterns = [compile_pattern(p) for p in config["all_of"]]
280
+ # Filter out invalid patterns
281
+ compiled_patterns = [p for p in compiled_patterns if p]
282
+
283
+ if not compiled_patterns:
284
+ return False, []
285
+
286
+ matched = set()
287
+ for action in actions:
288
+ # Check if this action matches ALL patterns
289
+ if all(compiled.match(action) for compiled in compiled_patterns):
290
+ matched.add(action)
291
+
292
+ return len(matched) > 0, list(matched)
293
+
294
+ return False, []
@@ -0,0 +1,87 @@
1
+ """Wildcard action expansion utilities for IAM policy checks.
2
+
3
+ This module provides functionality to expand wildcard actions (like ec2:*, iam:Delete*)
4
+ to their actual action names using the AWS Service Reference API.
5
+ """
6
+
7
+ import re
8
+ from functools import lru_cache
9
+ from re import Pattern
10
+
11
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
12
+
13
+
14
+ # Global cache for compiled wildcard patterns (shared across checks)
15
+ # Using lru_cache for O(1) pattern reuse and 20-30x performance improvement
16
+ @lru_cache(maxsize=512)
17
+ def compile_wildcard_pattern(pattern: str) -> Pattern[str]:
18
+ """Compile and cache wildcard patterns for O(1) reuse.
19
+
20
+ Args:
21
+ pattern: Wildcard pattern (e.g., "s3:Get*")
22
+
23
+ Returns:
24
+ Compiled regex pattern
25
+
26
+ Performance:
27
+ 20-30x speedup by avoiding repeated pattern compilation
28
+ """
29
+ regex_pattern = "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
30
+ return re.compile(regex_pattern, re.IGNORECASE)
31
+
32
+
33
+ async def expand_wildcard_actions(actions: list[str], fetcher: AWSServiceFetcher) -> list[str]:
34
+ """
35
+ Expand wildcard actions to their actual action names using AWS API.
36
+
37
+ This function expands wildcard patterns like "s3:*", "ec2:Delete*", "iam:*User*"
38
+ to the actual action names they grant. This is crucial for sensitive action
39
+ detection to catch wildcards that include sensitive actions.
40
+
41
+ Examples:
42
+ ["s3:GetObject", "ec2:*"] -> ["s3:GetObject", "ec2:DeleteVolume", "ec2:TerminateInstances", ...]
43
+ ["iam:Delete*"] -> ["iam:DeleteUser", "iam:DeleteRole", "iam:DeleteAccessKey", ...]
44
+
45
+ Args:
46
+ actions: List of action patterns (may include wildcards)
47
+ fetcher: AWS service fetcher for API lookups
48
+
49
+ Returns:
50
+ List of expanded action names (wildcards replaced with actual actions)
51
+ """
52
+ expanded = []
53
+
54
+ for action in actions:
55
+ # Skip full wildcard "*" - it's too broad to expand
56
+ if action == "*":
57
+ expanded.append(action)
58
+ continue
59
+
60
+ # Check if action contains wildcards
61
+ if "*" not in action:
62
+ # No wildcard, keep as-is
63
+ expanded.append(action)
64
+ continue
65
+
66
+ # Action has wildcard - expand it using AWS API
67
+ try:
68
+ # Parse action to get service and action name
69
+ service_prefix, action_name = fetcher.parse_action(action)
70
+
71
+ # Fetch service detail to get all available actions
72
+ service_detail = await fetcher.fetch_service_by_name(service_prefix)
73
+ available_actions = list(service_detail.actions.keys())
74
+
75
+ # Match wildcard pattern against available actions
76
+ _, matched_actions = fetcher._match_wildcard_action(action_name, available_actions)
77
+
78
+ # Add expanded actions with service prefix
79
+ for matched_action in matched_actions:
80
+ expanded.append(f"{service_prefix}:{matched_action}")
81
+
82
+ except Exception:
83
+ # If expansion fails (invalid service, etc.), keep original action
84
+ # This ensures we don't lose actions due to API errors
85
+ expanded.append(action)
86
+
87
+ return expanded