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