iam-policy-validator 1.4.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.4.0.dist-info/METADATA +1022 -0
- iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
- iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.4.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 +27 -0
- iam_validator/checks/action_condition_enforcement.py +727 -0
- iam_validator/checks/action_resource_constraint.py +151 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +70 -0
- iam_validator/checks/policy_size.py +151 -0
- iam_validator/checks/policy_type_validation.py +299 -0
- iam_validator/checks/principal_validation.py +282 -0
- iam_validator/checks/resource_validation.py +108 -0
- iam_validator/checks/security_best_practices.py +536 -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 +252 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +434 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +260 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +539 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +666 -0
- iam_validator/core/access_analyzer_report.py +643 -0
- iam_validator/core/aws_fetcher.py +880 -0
- iam_validator/core/aws_global_conditions.py +137 -0
- iam_validator/core/check_registry.py +469 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/config_loader.py +452 -0
- iam_validator/core/defaults.py +393 -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 +434 -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 +187 -0
- iam_validator/core/models.py +298 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +338 -0
- iam_validator/core/report.py +859 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +795 -0
- iam_validator/integrations/ms_teams.py +442 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Action-Specific Condition Enforcement Check (Unified)
|
|
3
|
+
|
|
4
|
+
This built-in check ensures that specific actions have required conditions.
|
|
5
|
+
Supports ALL types of conditions: MFA, IP, VPC, time, tags, encryption, etc.
|
|
6
|
+
|
|
7
|
+
Supports advanced "all_of" and "any_of" logic for both actions and conditions.
|
|
8
|
+
|
|
9
|
+
Common use cases:
|
|
10
|
+
- iam:PassRole must have iam:PassedToService condition
|
|
11
|
+
- Sensitive actions must have MFA conditions
|
|
12
|
+
- Actions must have source IP restrictions
|
|
13
|
+
- Resources must have required tags
|
|
14
|
+
- Combine multiple conditions (MFA + IP + Tags)
|
|
15
|
+
|
|
16
|
+
Configuration in iam-validator.yaml:
|
|
17
|
+
|
|
18
|
+
checks:
|
|
19
|
+
action_condition_enforcement:
|
|
20
|
+
enabled: true
|
|
21
|
+
severity: error
|
|
22
|
+
description: "Enforce specific conditions for specific actions"
|
|
23
|
+
|
|
24
|
+
action_condition_requirements:
|
|
25
|
+
# BASIC: Simple action with required condition
|
|
26
|
+
- actions:
|
|
27
|
+
- "iam:PassRole"
|
|
28
|
+
required_conditions:
|
|
29
|
+
- condition_key: "iam:PassedToService"
|
|
30
|
+
description: "Specify which AWS services can use the passed role"
|
|
31
|
+
|
|
32
|
+
# MFA + IP restrictions
|
|
33
|
+
- actions:
|
|
34
|
+
- "iam:DeleteUser"
|
|
35
|
+
required_conditions:
|
|
36
|
+
all_of:
|
|
37
|
+
- condition_key: "aws:MultiFactorAuthPresent"
|
|
38
|
+
expected_value: true
|
|
39
|
+
- condition_key: "aws:SourceIp"
|
|
40
|
+
|
|
41
|
+
# EC2 with TAGS + MFA + Region
|
|
42
|
+
- actions:
|
|
43
|
+
- "ec2:RunInstances"
|
|
44
|
+
required_conditions:
|
|
45
|
+
all_of:
|
|
46
|
+
- condition_key: "aws:MultiFactorAuthPresent"
|
|
47
|
+
expected_value: true
|
|
48
|
+
- condition_key: "aws:RequestTag/Environment"
|
|
49
|
+
operator: "StringEquals"
|
|
50
|
+
expected_value: ["Production", "Staging", "Development"]
|
|
51
|
+
- condition_key: "aws:RequestTag/Owner"
|
|
52
|
+
- condition_key: "aws:RequestedRegion"
|
|
53
|
+
expected_value: ["us-east-1", "us-west-2"]
|
|
54
|
+
|
|
55
|
+
# Principal-to-resource tag matching
|
|
56
|
+
- actions:
|
|
57
|
+
- "ec2:RunInstances"
|
|
58
|
+
required_conditions:
|
|
59
|
+
- condition_key: "aws:ResourceTag/owner"
|
|
60
|
+
operator: "StringEquals"
|
|
61
|
+
expected_value: "${aws:PrincipalTag/owner}"
|
|
62
|
+
description: "Resource owner must match principal's owner tag"
|
|
63
|
+
|
|
64
|
+
# Complex: all_of + any_of for actions and conditions
|
|
65
|
+
- actions:
|
|
66
|
+
any_of:
|
|
67
|
+
- "cloudformation:CreateStack"
|
|
68
|
+
- "cloudformation:UpdateStack"
|
|
69
|
+
required_conditions:
|
|
70
|
+
all_of:
|
|
71
|
+
- condition_key: "aws:MultiFactorAuthPresent"
|
|
72
|
+
expected_value: true
|
|
73
|
+
- condition_key: "aws:RequestTag/Environment"
|
|
74
|
+
any_of:
|
|
75
|
+
- condition_key: "aws:SourceIp"
|
|
76
|
+
- condition_key: "aws:SourceVpce"
|
|
77
|
+
|
|
78
|
+
# none_of for conditions: Ensure certain conditions are NOT present
|
|
79
|
+
- actions:
|
|
80
|
+
- "s3:GetObject"
|
|
81
|
+
required_conditions:
|
|
82
|
+
none_of:
|
|
83
|
+
- condition_key: "aws:SecureTransport"
|
|
84
|
+
expected_value: false
|
|
85
|
+
description: "Ensure insecure transport is never allowed"
|
|
86
|
+
|
|
87
|
+
# none_of for actions: Flag if forbidden actions are present
|
|
88
|
+
- actions:
|
|
89
|
+
none_of:
|
|
90
|
+
- "iam:*"
|
|
91
|
+
- "s3:DeleteBucket"
|
|
92
|
+
description: "These dangerous actions should never be used"
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
import re
|
|
96
|
+
from typing import Any
|
|
97
|
+
|
|
98
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
99
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
100
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ActionConditionEnforcementCheck(PolicyCheck):
|
|
104
|
+
"""Enforces specific condition requirements for specific actions with all_of/any_of support."""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def check_id(self) -> str:
|
|
108
|
+
return "action_condition_enforcement"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def description(self) -> str:
|
|
112
|
+
return "Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def default_severity(self) -> str:
|
|
116
|
+
return "error"
|
|
117
|
+
|
|
118
|
+
async def execute(
|
|
119
|
+
self,
|
|
120
|
+
statement: Statement,
|
|
121
|
+
statement_idx: int,
|
|
122
|
+
fetcher: AWSServiceFetcher,
|
|
123
|
+
config: CheckConfig,
|
|
124
|
+
) -> list[ValidationIssue]:
|
|
125
|
+
"""Execute condition enforcement check."""
|
|
126
|
+
issues = []
|
|
127
|
+
|
|
128
|
+
# Only check Allow statements
|
|
129
|
+
if statement.effect != "Allow":
|
|
130
|
+
return issues
|
|
131
|
+
|
|
132
|
+
# Get action condition requirements from config
|
|
133
|
+
action_condition_requirements = config.config.get("action_condition_requirements", [])
|
|
134
|
+
if not action_condition_requirements:
|
|
135
|
+
return issues
|
|
136
|
+
|
|
137
|
+
statement_actions = statement.get_actions()
|
|
138
|
+
|
|
139
|
+
# Check each requirement rule
|
|
140
|
+
for requirement in action_condition_requirements:
|
|
141
|
+
# Check if this requirement applies to the statement's actions
|
|
142
|
+
actions_match, matching_actions = await self._check_action_match(
|
|
143
|
+
statement_actions, requirement, fetcher
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not actions_match:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Check if this is a none_of action rule (forbidden actions)
|
|
150
|
+
actions_config = requirement.get("actions", [])
|
|
151
|
+
if isinstance(actions_config, dict) and "none_of" in actions_config:
|
|
152
|
+
# This is a forbidden action rule - flag it
|
|
153
|
+
description = requirement.get("description", "These actions should not be used")
|
|
154
|
+
# Use per-requirement severity if specified, else use global
|
|
155
|
+
severity = requirement.get("severity", self.get_severity(config))
|
|
156
|
+
issues.append(
|
|
157
|
+
ValidationIssue(
|
|
158
|
+
severity=severity,
|
|
159
|
+
statement_sid=statement.sid,
|
|
160
|
+
statement_index=statement_idx,
|
|
161
|
+
issue_type="forbidden_action_present",
|
|
162
|
+
message=f"FORBIDDEN: Actions {matching_actions} should not be used. {description}",
|
|
163
|
+
action=", ".join(matching_actions),
|
|
164
|
+
suggestion=f"Remove these forbidden actions from the statement: {', '.join(matching_actions)}. {description}",
|
|
165
|
+
line_number=statement.line_number,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
# Actions match - now validate required conditions
|
|
171
|
+
required_conditions_config = requirement.get("required_conditions", [])
|
|
172
|
+
if not required_conditions_config:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Check if conditions are in all_of/any_of/none_of format or simple list
|
|
176
|
+
condition_issues = self._validate_conditions(
|
|
177
|
+
statement,
|
|
178
|
+
statement_idx,
|
|
179
|
+
required_conditions_config,
|
|
180
|
+
matching_actions,
|
|
181
|
+
config,
|
|
182
|
+
requirement, # Pass the full requirement for severity override
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
issues.extend(condition_issues)
|
|
186
|
+
|
|
187
|
+
return issues
|
|
188
|
+
|
|
189
|
+
async def _check_action_match(
|
|
190
|
+
self, statement_actions: list[str], requirement: dict[str, Any], fetcher: AWSServiceFetcher
|
|
191
|
+
) -> tuple[bool, list[str]]:
|
|
192
|
+
"""
|
|
193
|
+
Check if statement actions match the requirement.
|
|
194
|
+
Supports: simple list, all_of, any_of, none_of, and action_patterns.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
(matches, list_of_matching_actions)
|
|
198
|
+
"""
|
|
199
|
+
actions_config = requirement.get("actions", [])
|
|
200
|
+
action_patterns = requirement.get("action_patterns", [])
|
|
201
|
+
|
|
202
|
+
matching_actions: list[str] = []
|
|
203
|
+
|
|
204
|
+
# Handle simple list format (backward compatibility)
|
|
205
|
+
if isinstance(actions_config, list) and actions_config:
|
|
206
|
+
# Simple list - check if any action matches
|
|
207
|
+
for stmt_action in statement_actions:
|
|
208
|
+
if stmt_action == "*":
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Check if this statement action matches any of the required actions or patterns
|
|
212
|
+
# Use _action_matches which handles wildcards in both statement and config
|
|
213
|
+
matched = False
|
|
214
|
+
|
|
215
|
+
# Check against configured actions
|
|
216
|
+
for required_action in actions_config:
|
|
217
|
+
if await self._action_matches(
|
|
218
|
+
stmt_action, required_action, action_patterns, fetcher
|
|
219
|
+
):
|
|
220
|
+
matched = True
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
# If not matched by actions, check if wildcard overlaps with patterns
|
|
224
|
+
if not matched and "*" in stmt_action:
|
|
225
|
+
# For wildcards, also check pattern overlap directly
|
|
226
|
+
matched = await self._action_matches(stmt_action, "", action_patterns, fetcher)
|
|
227
|
+
|
|
228
|
+
if matched and stmt_action not in matching_actions:
|
|
229
|
+
matching_actions.append(stmt_action)
|
|
230
|
+
|
|
231
|
+
return len(matching_actions) > 0, matching_actions
|
|
232
|
+
|
|
233
|
+
# Handle all_of/any_of/none_of format
|
|
234
|
+
if isinstance(actions_config, dict):
|
|
235
|
+
all_of = actions_config.get("all_of", [])
|
|
236
|
+
any_of = actions_config.get("any_of", [])
|
|
237
|
+
none_of = actions_config.get("none_of", [])
|
|
238
|
+
|
|
239
|
+
# Check all_of: ALL specified actions must be in statement
|
|
240
|
+
if all_of:
|
|
241
|
+
all_present = True
|
|
242
|
+
for req_action in all_of:
|
|
243
|
+
found = False
|
|
244
|
+
for stmt_action in statement_actions:
|
|
245
|
+
if await self._action_matches(
|
|
246
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
247
|
+
):
|
|
248
|
+
found = True
|
|
249
|
+
break
|
|
250
|
+
if not found:
|
|
251
|
+
all_present = False
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
if not all_present:
|
|
255
|
+
return False, []
|
|
256
|
+
|
|
257
|
+
# Collect matching actions
|
|
258
|
+
for stmt_action in statement_actions:
|
|
259
|
+
for req_action in all_of:
|
|
260
|
+
if await self._action_matches(
|
|
261
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
262
|
+
):
|
|
263
|
+
if stmt_action not in matching_actions:
|
|
264
|
+
matching_actions.append(stmt_action)
|
|
265
|
+
|
|
266
|
+
# Check any_of: At least ONE specified action must be in statement
|
|
267
|
+
if any_of:
|
|
268
|
+
any_present = False
|
|
269
|
+
for stmt_action in statement_actions:
|
|
270
|
+
for req_action in any_of:
|
|
271
|
+
if await self._action_matches(
|
|
272
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
273
|
+
):
|
|
274
|
+
any_present = True
|
|
275
|
+
if stmt_action not in matching_actions:
|
|
276
|
+
matching_actions.append(stmt_action)
|
|
277
|
+
|
|
278
|
+
if not any_present:
|
|
279
|
+
return False, []
|
|
280
|
+
|
|
281
|
+
# Check none_of: NONE of the specified actions should be in statement
|
|
282
|
+
if none_of:
|
|
283
|
+
forbidden_actions = []
|
|
284
|
+
for stmt_action in statement_actions:
|
|
285
|
+
for forbidden_action in none_of:
|
|
286
|
+
if await self._action_matches(
|
|
287
|
+
stmt_action, forbidden_action, action_patterns, fetcher
|
|
288
|
+
):
|
|
289
|
+
forbidden_actions.append(stmt_action)
|
|
290
|
+
|
|
291
|
+
# If forbidden actions are found, this is a match for flagging
|
|
292
|
+
if forbidden_actions:
|
|
293
|
+
return True, forbidden_actions
|
|
294
|
+
|
|
295
|
+
return len(matching_actions) > 0, matching_actions
|
|
296
|
+
|
|
297
|
+
return False, []
|
|
298
|
+
|
|
299
|
+
async def _action_matches(
|
|
300
|
+
self,
|
|
301
|
+
statement_action: str,
|
|
302
|
+
required_action: str,
|
|
303
|
+
patterns: list[str],
|
|
304
|
+
fetcher: AWSServiceFetcher,
|
|
305
|
+
) -> bool:
|
|
306
|
+
"""
|
|
307
|
+
Check if a statement action matches a required action or pattern.
|
|
308
|
+
Supports:
|
|
309
|
+
- Exact matches: "s3:GetObject"
|
|
310
|
+
- AWS wildcards in both statement and required actions: "s3:*", "s3:Get*", "iam:Creat*"
|
|
311
|
+
- Regex patterns: "^s3:Get.*", "^iam:Delete.*"
|
|
312
|
+
|
|
313
|
+
This method handles bidirectional wildcard matching using real AWS actions from the fetcher:
|
|
314
|
+
- statement_action="iam:Create*" matches required_action="iam:CreateUser"
|
|
315
|
+
- statement_action="iam:C*" matches pattern="^iam:Create" (by checking actual AWS actions)
|
|
316
|
+
"""
|
|
317
|
+
if statement_action == "*":
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
# Exact match
|
|
321
|
+
if statement_action == required_action:
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
# AWS wildcard match in required_action (e.g., "s3:*", "s3:Get*")
|
|
325
|
+
if "*" in required_action:
|
|
326
|
+
# Convert AWS wildcard to regex
|
|
327
|
+
wildcard_pattern = required_action.replace("*", ".*").replace("?", ".")
|
|
328
|
+
try:
|
|
329
|
+
if re.match(f"^{wildcard_pattern}$", statement_action):
|
|
330
|
+
return True
|
|
331
|
+
except re.error:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
# AWS wildcard match in statement_action (e.g., "iam:Creat*" in policy)
|
|
335
|
+
# Check if this wildcard would grant access to actions matching our patterns
|
|
336
|
+
if "*" in statement_action:
|
|
337
|
+
# Convert statement wildcard to regex pattern
|
|
338
|
+
stmt_wildcard_pattern = statement_action.replace("*", ".*").replace("?", ".")
|
|
339
|
+
|
|
340
|
+
# Check if statement wildcard overlaps with required action
|
|
341
|
+
if "*" not in required_action:
|
|
342
|
+
# Required action is specific (e.g., "iam:CreateUser")
|
|
343
|
+
# Check if statement wildcard would grant it
|
|
344
|
+
try:
|
|
345
|
+
if re.match(f"^{stmt_wildcard_pattern}$", required_action):
|
|
346
|
+
return True
|
|
347
|
+
except re.error:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
# Check if statement wildcard overlaps with any of our action patterns
|
|
351
|
+
# Strategy: Use real AWS actions from the fetcher instead of hardcoded guesses
|
|
352
|
+
# For example: "iam:C*" should match pattern "^iam:Create" because:
|
|
353
|
+
# - "iam:C*" grants iam:CreateUser, iam:CreateRole, etc. (from AWS)
|
|
354
|
+
# - "^iam:Create" pattern is meant to catch iam:CreateUser, iam:CreateRole, etc.
|
|
355
|
+
# - Therefore they overlap
|
|
356
|
+
if patterns:
|
|
357
|
+
try:
|
|
358
|
+
# Parse the service from the wildcard action
|
|
359
|
+
service_prefix, _ = fetcher.parse_action(statement_action)
|
|
360
|
+
|
|
361
|
+
# Fetch the real list of actions for this service
|
|
362
|
+
service_detail = await fetcher.fetch_service_by_name(service_prefix)
|
|
363
|
+
available_actions = list(service_detail.actions.keys())
|
|
364
|
+
|
|
365
|
+
# Find which actual AWS actions the wildcard would grant
|
|
366
|
+
_, granted_actions = fetcher._match_wildcard_action(
|
|
367
|
+
statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
|
|
368
|
+
available_actions,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Check if any of the granted actions match our patterns
|
|
372
|
+
for granted_action in granted_actions:
|
|
373
|
+
full_granted_action = f"{service_prefix}:{granted_action}"
|
|
374
|
+
for pattern in patterns:
|
|
375
|
+
try:
|
|
376
|
+
if re.match(pattern, full_granted_action):
|
|
377
|
+
return True
|
|
378
|
+
except re.error:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
except (ValueError, Exception):
|
|
382
|
+
# If we can't fetch the service or parse the action, fall back to prefix matching
|
|
383
|
+
stmt_prefix = statement_action.rstrip("*")
|
|
384
|
+
for pattern in patterns:
|
|
385
|
+
try:
|
|
386
|
+
if re.match(pattern, stmt_prefix):
|
|
387
|
+
return True
|
|
388
|
+
except re.error:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
# Regex pattern match (from action_patterns config)
|
|
392
|
+
for pattern in patterns:
|
|
393
|
+
try:
|
|
394
|
+
if re.match(pattern, statement_action):
|
|
395
|
+
return True
|
|
396
|
+
except re.error:
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
def _validate_conditions(
|
|
402
|
+
self,
|
|
403
|
+
statement: Statement,
|
|
404
|
+
statement_idx: int,
|
|
405
|
+
required_conditions_config: Any,
|
|
406
|
+
matching_actions: list[str],
|
|
407
|
+
config: CheckConfig,
|
|
408
|
+
requirement: dict[str, Any] | None = None,
|
|
409
|
+
) -> list[ValidationIssue]:
|
|
410
|
+
"""
|
|
411
|
+
Validate that required conditions are present.
|
|
412
|
+
Supports: simple list, all_of, any_of formats.
|
|
413
|
+
Can use per-requirement severity override from requirement['severity'].
|
|
414
|
+
"""
|
|
415
|
+
issues: list[ValidationIssue] = []
|
|
416
|
+
|
|
417
|
+
# Handle simple list format (backward compatibility)
|
|
418
|
+
if isinstance(required_conditions_config, list):
|
|
419
|
+
for condition_requirement in required_conditions_config:
|
|
420
|
+
if not self._has_condition_requirement(statement, condition_requirement):
|
|
421
|
+
issues.append(
|
|
422
|
+
self._create_issue(
|
|
423
|
+
statement,
|
|
424
|
+
statement_idx,
|
|
425
|
+
condition_requirement,
|
|
426
|
+
matching_actions,
|
|
427
|
+
config,
|
|
428
|
+
requirement=requirement,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
return issues
|
|
432
|
+
|
|
433
|
+
# Handle all_of/any_of/none_of format
|
|
434
|
+
if isinstance(required_conditions_config, dict):
|
|
435
|
+
all_of = required_conditions_config.get("all_of", [])
|
|
436
|
+
any_of = required_conditions_config.get("any_of", [])
|
|
437
|
+
none_of = required_conditions_config.get("none_of", [])
|
|
438
|
+
|
|
439
|
+
# Validate all_of: ALL conditions must be present
|
|
440
|
+
if all_of:
|
|
441
|
+
for condition_requirement in all_of:
|
|
442
|
+
if not self._has_condition_requirement(statement, condition_requirement):
|
|
443
|
+
issues.append(
|
|
444
|
+
self._create_issue(
|
|
445
|
+
statement,
|
|
446
|
+
statement_idx,
|
|
447
|
+
condition_requirement,
|
|
448
|
+
matching_actions,
|
|
449
|
+
config,
|
|
450
|
+
requirement_type="all_of",
|
|
451
|
+
requirement=requirement,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Validate any_of: At least ONE condition must be present
|
|
456
|
+
if any_of:
|
|
457
|
+
any_present = any(
|
|
458
|
+
self._has_condition_requirement(statement, cond_req) for cond_req in any_of
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if not any_present:
|
|
462
|
+
# Create a combined error for any_of
|
|
463
|
+
condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
|
|
464
|
+
issues.append(
|
|
465
|
+
ValidationIssue(
|
|
466
|
+
severity=self.get_severity(config),
|
|
467
|
+
statement_sid=statement.sid,
|
|
468
|
+
statement_index=statement_idx,
|
|
469
|
+
issue_type="missing_required_condition_any_of",
|
|
470
|
+
message=(
|
|
471
|
+
f"Actions {matching_actions} require at least ONE of these conditions: "
|
|
472
|
+
f"{', '.join(condition_keys)}"
|
|
473
|
+
),
|
|
474
|
+
action=", ".join(matching_actions),
|
|
475
|
+
suggestion=self._build_any_of_suggestion(any_of),
|
|
476
|
+
line_number=statement.line_number,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Validate none_of: NONE of these conditions should be present
|
|
481
|
+
if none_of:
|
|
482
|
+
for condition_requirement in none_of:
|
|
483
|
+
if self._has_condition_requirement(statement, condition_requirement):
|
|
484
|
+
issues.append(
|
|
485
|
+
self._create_none_of_issue(
|
|
486
|
+
statement,
|
|
487
|
+
statement_idx,
|
|
488
|
+
condition_requirement,
|
|
489
|
+
matching_actions,
|
|
490
|
+
config,
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return issues
|
|
495
|
+
|
|
496
|
+
def _has_condition_requirement(
|
|
497
|
+
self, statement: Statement, condition_requirement: dict[str, Any]
|
|
498
|
+
) -> bool:
|
|
499
|
+
"""Check if statement has the required condition."""
|
|
500
|
+
condition_key = condition_requirement.get("condition_key")
|
|
501
|
+
if not condition_key:
|
|
502
|
+
return True # No condition key specified, skip
|
|
503
|
+
|
|
504
|
+
operator = condition_requirement.get("operator")
|
|
505
|
+
expected_value = condition_requirement.get("expected_value")
|
|
506
|
+
|
|
507
|
+
return self._has_condition(statement, condition_key, operator, expected_value)
|
|
508
|
+
|
|
509
|
+
def _has_condition(
|
|
510
|
+
self,
|
|
511
|
+
statement: Statement,
|
|
512
|
+
condition_key: str,
|
|
513
|
+
operator: str | None = None,
|
|
514
|
+
expected_value: Any = None,
|
|
515
|
+
) -> bool:
|
|
516
|
+
"""
|
|
517
|
+
Check if statement has the specified condition key.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
statement: The IAM policy statement
|
|
521
|
+
condition_key: The condition key to look for
|
|
522
|
+
operator: Optional specific operator (e.g., "StringEquals")
|
|
523
|
+
expected_value: Optional expected value for the condition
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
True if condition is present (and matches expected value if specified)
|
|
527
|
+
"""
|
|
528
|
+
if not statement.condition:
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
# If operator specified, only check that operator
|
|
532
|
+
operators_to_check = [operator] if operator else list(statement.condition.keys())
|
|
533
|
+
|
|
534
|
+
# Look through specified condition operators
|
|
535
|
+
for op in operators_to_check:
|
|
536
|
+
if op not in statement.condition:
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
conditions = statement.condition[op]
|
|
540
|
+
if isinstance(conditions, dict):
|
|
541
|
+
if condition_key in conditions:
|
|
542
|
+
# If no expected value specified, just presence is enough
|
|
543
|
+
if expected_value is None:
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
# Check if the value matches
|
|
547
|
+
actual_value = conditions[condition_key]
|
|
548
|
+
|
|
549
|
+
# Handle boolean values
|
|
550
|
+
if isinstance(expected_value, bool):
|
|
551
|
+
if isinstance(actual_value, bool):
|
|
552
|
+
return actual_value == expected_value
|
|
553
|
+
if isinstance(actual_value, str):
|
|
554
|
+
return actual_value.lower() == str(expected_value).lower()
|
|
555
|
+
|
|
556
|
+
# Handle exact matches
|
|
557
|
+
if actual_value == expected_value:
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
# Handle list values (actual can be string or list)
|
|
561
|
+
if isinstance(expected_value, list):
|
|
562
|
+
if isinstance(actual_value, list):
|
|
563
|
+
return set(expected_value) == set(actual_value)
|
|
564
|
+
if actual_value in expected_value:
|
|
565
|
+
return True
|
|
566
|
+
|
|
567
|
+
# Handle string matches for variable references like ${aws:PrincipalTag/owner}
|
|
568
|
+
if str(actual_value) == str(expected_value):
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
def _create_issue(
|
|
574
|
+
self,
|
|
575
|
+
statement: Statement,
|
|
576
|
+
statement_idx: int,
|
|
577
|
+
condition_requirement: dict[str, Any],
|
|
578
|
+
matching_actions: list[str],
|
|
579
|
+
config: CheckConfig,
|
|
580
|
+
requirement_type: str = "required",
|
|
581
|
+
requirement: dict[str, Any] | None = None,
|
|
582
|
+
) -> ValidationIssue:
|
|
583
|
+
"""Create a validation issue for a missing condition.
|
|
584
|
+
|
|
585
|
+
Severity precedence:
|
|
586
|
+
1. Individual condition requirement's severity (condition_requirement['severity'])
|
|
587
|
+
2. Parent requirement's severity (requirement['severity'])
|
|
588
|
+
3. Global check severity (config.severity)
|
|
589
|
+
"""
|
|
590
|
+
condition_key = condition_requirement.get("condition_key", "unknown")
|
|
591
|
+
description = condition_requirement.get("description", "")
|
|
592
|
+
expected_value = condition_requirement.get("expected_value")
|
|
593
|
+
example = condition_requirement.get("example", "")
|
|
594
|
+
operator = condition_requirement.get("operator", "StringEquals")
|
|
595
|
+
|
|
596
|
+
message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
|
|
597
|
+
|
|
598
|
+
# Determine severity with precedence: condition > requirement > global
|
|
599
|
+
severity = (
|
|
600
|
+
condition_requirement.get("severity") # Condition-level override
|
|
601
|
+
or (requirement.get("severity") if requirement else None) # Requirement-level override
|
|
602
|
+
or self.get_severity(config) # Global check severity
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return ValidationIssue(
|
|
606
|
+
severity=severity,
|
|
607
|
+
statement_sid=statement.sid,
|
|
608
|
+
statement_index=statement_idx,
|
|
609
|
+
issue_type="missing_required_condition",
|
|
610
|
+
message=(
|
|
611
|
+
f"{message_prefix} Action(s) {matching_actions} require condition '{condition_key}'. "
|
|
612
|
+
f"{description}"
|
|
613
|
+
),
|
|
614
|
+
action=", ".join(matching_actions),
|
|
615
|
+
condition_key=condition_key,
|
|
616
|
+
suggestion=self._build_suggestion(
|
|
617
|
+
condition_key, description, example, expected_value, operator
|
|
618
|
+
),
|
|
619
|
+
line_number=statement.line_number,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _build_suggestion(
|
|
623
|
+
self,
|
|
624
|
+
condition_key: str,
|
|
625
|
+
description: str,
|
|
626
|
+
example: str,
|
|
627
|
+
expected_value: Any = None,
|
|
628
|
+
operator: str = "StringEquals",
|
|
629
|
+
) -> str:
|
|
630
|
+
"""Build a helpful suggestion for adding the missing condition."""
|
|
631
|
+
parts = []
|
|
632
|
+
|
|
633
|
+
if description:
|
|
634
|
+
parts.append(description)
|
|
635
|
+
|
|
636
|
+
# Build example based on condition key type
|
|
637
|
+
if example:
|
|
638
|
+
parts.append(f"Example:\n{example}")
|
|
639
|
+
else:
|
|
640
|
+
# Auto-generate example
|
|
641
|
+
example_lines = ['Add to "Condition" block:', f' "{operator}": {{']
|
|
642
|
+
|
|
643
|
+
if isinstance(expected_value, list):
|
|
644
|
+
value_str = (
|
|
645
|
+
"["
|
|
646
|
+
+ ", ".join(
|
|
647
|
+
[
|
|
648
|
+
f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
|
|
649
|
+
for v in expected_value
|
|
650
|
+
]
|
|
651
|
+
)
|
|
652
|
+
+ "]"
|
|
653
|
+
)
|
|
654
|
+
elif expected_value is not None:
|
|
655
|
+
# Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
|
|
656
|
+
if str(expected_value).startswith("${"):
|
|
657
|
+
value_str = f'"{expected_value}"'
|
|
658
|
+
elif isinstance(expected_value, bool):
|
|
659
|
+
value_str = str(expected_value).lower()
|
|
660
|
+
else:
|
|
661
|
+
value_str = f'"{expected_value}"'
|
|
662
|
+
else:
|
|
663
|
+
value_str = '"<value>"'
|
|
664
|
+
|
|
665
|
+
example_lines.append(f' "{condition_key}": {value_str}')
|
|
666
|
+
example_lines.append(" }")
|
|
667
|
+
|
|
668
|
+
parts.append("\n".join(example_lines))
|
|
669
|
+
|
|
670
|
+
return ". ".join(parts) if parts else f"Add condition: {condition_key}"
|
|
671
|
+
|
|
672
|
+
def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
|
|
673
|
+
"""Build suggestion for any_of conditions."""
|
|
674
|
+
suggestions = []
|
|
675
|
+
suggestions.append("Add at least ONE of these conditions:")
|
|
676
|
+
|
|
677
|
+
for i, cond in enumerate(any_of_conditions, 1):
|
|
678
|
+
condition_key = cond.get("condition_key", "unknown")
|
|
679
|
+
description = cond.get("description", "")
|
|
680
|
+
expected_value = cond.get("expected_value")
|
|
681
|
+
|
|
682
|
+
option = f"\nOption {i}: {condition_key}"
|
|
683
|
+
if description:
|
|
684
|
+
option += f" - {description}"
|
|
685
|
+
if expected_value is not None:
|
|
686
|
+
option += f" (value: {expected_value})"
|
|
687
|
+
|
|
688
|
+
suggestions.append(option)
|
|
689
|
+
|
|
690
|
+
return "".join(suggestions)
|
|
691
|
+
|
|
692
|
+
def _create_none_of_issue(
|
|
693
|
+
self,
|
|
694
|
+
statement: Statement,
|
|
695
|
+
statement_idx: int,
|
|
696
|
+
condition_requirement: dict[str, Any],
|
|
697
|
+
matching_actions: list[str],
|
|
698
|
+
config: CheckConfig,
|
|
699
|
+
) -> ValidationIssue:
|
|
700
|
+
"""Create a validation issue for a forbidden condition that is present."""
|
|
701
|
+
condition_key = condition_requirement.get("condition_key", "unknown")
|
|
702
|
+
description = condition_requirement.get("description", "")
|
|
703
|
+
expected_value = condition_requirement.get("expected_value")
|
|
704
|
+
|
|
705
|
+
message = (
|
|
706
|
+
f"FORBIDDEN: Action(s) {matching_actions} must NOT have condition '{condition_key}'"
|
|
707
|
+
)
|
|
708
|
+
if expected_value is not None:
|
|
709
|
+
message += f" with value '{expected_value}'"
|
|
710
|
+
if description:
|
|
711
|
+
message += f". {description}"
|
|
712
|
+
|
|
713
|
+
suggestion = f"Remove the '{condition_key}' condition from the statement"
|
|
714
|
+
if description:
|
|
715
|
+
suggestion += f". {description}"
|
|
716
|
+
|
|
717
|
+
return ValidationIssue(
|
|
718
|
+
severity=self.get_severity(config),
|
|
719
|
+
statement_sid=statement.sid,
|
|
720
|
+
statement_index=statement_idx,
|
|
721
|
+
issue_type="forbidden_condition_present",
|
|
722
|
+
message=message,
|
|
723
|
+
action=", ".join(matching_actions),
|
|
724
|
+
condition_key=condition_key,
|
|
725
|
+
suggestion=suggestion,
|
|
726
|
+
line_number=statement.line_number,
|
|
727
|
+
)
|