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.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- 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
|