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,1442 @@
|
|
|
1
|
+
"""Action-Specific Condition Enforcement Check.
|
|
2
|
+
|
|
3
|
+
Ensures specific actions have required IAM conditions (MFA, IP, tags, etc.).
|
|
4
|
+
|
|
5
|
+
Action Matching Modes:
|
|
6
|
+
- Simple list: actions: ["iam:PassRole"]
|
|
7
|
+
- any_of: Require conditions if ANY action matches
|
|
8
|
+
- all_of: Require conditions if ALL actions present (overly permissive detection)
|
|
9
|
+
- none_of: Flag forbidden actions
|
|
10
|
+
|
|
11
|
+
Merge Strategies (merge_strategy setting):
|
|
12
|
+
- append (default): User + defaults both apply
|
|
13
|
+
- user_only: Disable ALL defaults, use only user requirements
|
|
14
|
+
- per_action_override: User replaces defaults for matching actions
|
|
15
|
+
- replace_all: User replaces all if provided
|
|
16
|
+
- defaults_only: Ignore user, use only defaults
|
|
17
|
+
|
|
18
|
+
For full documentation, see: docs/condition-requirements.md
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
25
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
26
|
+
from iam_validator.core.ignore_patterns import IgnorePatternMatcher
|
|
27
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
28
|
+
from iam_validator.utils.regex import compile_and_cache
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from iam_validator.core.models import IAMPolicy
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ActionConditionEnforcementCheck(PolicyCheck):
|
|
35
|
+
"""Enforces specific condition requirements for specific actions with all_of/any_of support."""
|
|
36
|
+
|
|
37
|
+
check_id: ClassVar[str] = "action_condition_enforcement"
|
|
38
|
+
description: ClassVar[str] = (
|
|
39
|
+
"Enforces conditions (MFA, IP, tags, etc.) for specific actions (supports all_of/any_of)"
|
|
40
|
+
)
|
|
41
|
+
default_severity: ClassVar[str] = "error"
|
|
42
|
+
|
|
43
|
+
async def execute_policy(
|
|
44
|
+
self,
|
|
45
|
+
policy: "IAMPolicy",
|
|
46
|
+
policy_file: str,
|
|
47
|
+
fetcher: AWSServiceFetcher,
|
|
48
|
+
config: CheckConfig,
|
|
49
|
+
**kwargs,
|
|
50
|
+
) -> list[ValidationIssue]:
|
|
51
|
+
"""
|
|
52
|
+
Execute policy-wide condition enforcement check.
|
|
53
|
+
|
|
54
|
+
This method scans the entire policy once and enforces conditions based on action matching:
|
|
55
|
+
- Simple list: Checks each statement for matching actions
|
|
56
|
+
- all_of: Finds statements that contain ALL specified actions (overly permissive detection)
|
|
57
|
+
- any_of: Finds statements that contain ANY of the specified actions
|
|
58
|
+
- none_of: Flags statements that contain forbidden actions
|
|
59
|
+
|
|
60
|
+
Example use cases:
|
|
61
|
+
- any_of: "If ANY statement grants iam:CreateUser, iam:AttachUserPolicy,
|
|
62
|
+
or iam:PutUserPolicy, then ALL such statements must have MFA condition."
|
|
63
|
+
- all_of: "Flag statements that grant BOTH iam:CreateAccessKey AND
|
|
64
|
+
iam:UpdateAccessKey (overly permissive)"
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
policy: The complete IAM policy to check
|
|
68
|
+
policy_file: Path to the policy file (for context/reporting)
|
|
69
|
+
fetcher: AWS service fetcher for validation against AWS APIs
|
|
70
|
+
config: CheckConfig: Configuration for this check instance
|
|
71
|
+
**kwargs: Additional context (policy_type, etc.)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of ValidationIssue objects found by this check
|
|
75
|
+
"""
|
|
76
|
+
del kwargs # Not used in current implementation
|
|
77
|
+
issues = []
|
|
78
|
+
|
|
79
|
+
# Get action condition requirements using configurable merge strategy
|
|
80
|
+
requirements = self._get_merged_requirements(config, policy_file)
|
|
81
|
+
|
|
82
|
+
if not requirements:
|
|
83
|
+
return issues
|
|
84
|
+
|
|
85
|
+
# Process each requirement
|
|
86
|
+
for requirement in requirements:
|
|
87
|
+
# Check if actions use all_of/any_of/none_of (policy-wide) or simple list (per-statement)
|
|
88
|
+
actions_config = requirement.get("actions", [])
|
|
89
|
+
uses_logical_operators = isinstance(actions_config, dict) and any(
|
|
90
|
+
key in actions_config for key in ("all_of", "any_of", "none_of")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if uses_logical_operators:
|
|
94
|
+
# Policy-wide detection (all_of/any_of/none_of)
|
|
95
|
+
policy_issues = await self._check_policy_wide(policy, requirement, fetcher, config)
|
|
96
|
+
# Filter by requirement-level ignore_patterns
|
|
97
|
+
policy_issues = self._filter_requirement_issues(
|
|
98
|
+
policy_issues, requirement.get("ignore_patterns", []), policy_file
|
|
99
|
+
)
|
|
100
|
+
issues.extend(policy_issues)
|
|
101
|
+
else:
|
|
102
|
+
# Per-statement check (simple list)
|
|
103
|
+
statement_issues = await self._check_per_statement(
|
|
104
|
+
policy, requirement, fetcher, config
|
|
105
|
+
)
|
|
106
|
+
# Filter by requirement-level ignore_patterns
|
|
107
|
+
statement_issues = self._filter_requirement_issues(
|
|
108
|
+
statement_issues, requirement.get("ignore_patterns", []), policy_file
|
|
109
|
+
)
|
|
110
|
+
issues.extend(statement_issues)
|
|
111
|
+
|
|
112
|
+
return issues
|
|
113
|
+
|
|
114
|
+
def _get_merged_requirements(
|
|
115
|
+
self,
|
|
116
|
+
config: CheckConfig,
|
|
117
|
+
policy_file: str,
|
|
118
|
+
) -> list[dict[str, Any]]:
|
|
119
|
+
"""
|
|
120
|
+
Get merged requirements based on configured merge strategy.
|
|
121
|
+
|
|
122
|
+
Supports multiple merge strategies to control how user requirements
|
|
123
|
+
interact with default requirements:
|
|
124
|
+
- "per_action_override": User requirements replace defaults for matching actions (default)
|
|
125
|
+
- "append": User requirements added to defaults (both apply)
|
|
126
|
+
- "replace_all": User requirements completely replace ALL defaults
|
|
127
|
+
- "defaults_only": Ignore user requirements, use only defaults
|
|
128
|
+
- "user_only": Ignore defaults, use only user requirements
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config: Check configuration containing requirements and merge strategy
|
|
132
|
+
policy_file: Path to the policy file being checked (for ignore_patterns)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Merged list of requirements based on strategy
|
|
136
|
+
"""
|
|
137
|
+
# Get default and user requirements
|
|
138
|
+
default_requirements = config.config.get(
|
|
139
|
+
"requirements",
|
|
140
|
+
config.config.get("policy_level_requirements", []),
|
|
141
|
+
)
|
|
142
|
+
user_requirements = config.config.get("action_condition_requirements")
|
|
143
|
+
|
|
144
|
+
# Get merge strategy (default: append - both defaults and user requirements apply)
|
|
145
|
+
merge_strategy = config.config.get("merge_strategy", "append")
|
|
146
|
+
|
|
147
|
+
# For user_only, replace_all, and per_action_override:
|
|
148
|
+
# Filter user requirements by ignore_patterns BEFORE merging
|
|
149
|
+
# For append and defaults_only: ignore_patterns on user requirements still apply
|
|
150
|
+
if user_requirements:
|
|
151
|
+
active_user_requirements = self._filter_requirements_by_filepath(
|
|
152
|
+
user_requirements, policy_file
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
active_user_requirements = []
|
|
156
|
+
|
|
157
|
+
# Apply merge strategy
|
|
158
|
+
if merge_strategy == "user_only":
|
|
159
|
+
# Use ONLY user requirements - no defaults at all
|
|
160
|
+
# If a user requirement is filtered by ignore_patterns, it's simply not checked
|
|
161
|
+
return active_user_requirements
|
|
162
|
+
|
|
163
|
+
elif merge_strategy == "defaults_only":
|
|
164
|
+
# Use ONLY defaults - ignore all user requirements
|
|
165
|
+
return default_requirements
|
|
166
|
+
|
|
167
|
+
elif merge_strategy == "replace_all":
|
|
168
|
+
# User requirements completely replace ALL defaults (if user provided any)
|
|
169
|
+
# If no user requirements provided, fall back to defaults
|
|
170
|
+
if user_requirements: # Check original, not filtered
|
|
171
|
+
return active_user_requirements
|
|
172
|
+
return default_requirements
|
|
173
|
+
|
|
174
|
+
elif merge_strategy == "per_action_override":
|
|
175
|
+
# User requirements replace defaults for MATCHING actions only
|
|
176
|
+
# Non-matching defaults are kept
|
|
177
|
+
# Note: We use the ORIGINAL user_requirements to determine which actions
|
|
178
|
+
# are "user-defined" (even if filtered out by ignore_patterns)
|
|
179
|
+
return self._merge_per_action_override(
|
|
180
|
+
default_requirements, user_requirements or [], active_user_requirements
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
else: # "append" (default)
|
|
184
|
+
# Both defaults AND user requirements apply
|
|
185
|
+
# User requirements are added on top of defaults
|
|
186
|
+
return default_requirements + active_user_requirements
|
|
187
|
+
|
|
188
|
+
def _filter_requirements_by_filepath(
|
|
189
|
+
self,
|
|
190
|
+
requirements: list[dict[str, Any]],
|
|
191
|
+
policy_file: str,
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
"""
|
|
194
|
+
Filter out requirements that should be ignored for this file.
|
|
195
|
+
|
|
196
|
+
This handles ignore_patterns at the requirement level BEFORE merging,
|
|
197
|
+
allowing defaults to apply when user requirements are ignored.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
requirements: List of requirements to filter
|
|
201
|
+
policy_file: Path to the policy file being checked
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Filtered list of requirements (excluding ignored ones)
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
User defines: iam:CreateRole with ignore_patterns: [".*test/.*"]
|
|
208
|
+
When checking test/policy.json:
|
|
209
|
+
- User requirement is filtered out
|
|
210
|
+
- Default iam:CreateRole requirement can apply instead
|
|
211
|
+
"""
|
|
212
|
+
active_reqs = []
|
|
213
|
+
|
|
214
|
+
for req in requirements:
|
|
215
|
+
ignore_patterns = req.get("ignore_patterns", [])
|
|
216
|
+
|
|
217
|
+
if not ignore_patterns:
|
|
218
|
+
# No ignore patterns - include this requirement
|
|
219
|
+
active_reqs.append(req)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Check if any ignore pattern matches this file
|
|
223
|
+
should_ignore = self._should_ignore_filepath(policy_file, ignore_patterns)
|
|
224
|
+
|
|
225
|
+
if not should_ignore:
|
|
226
|
+
active_reqs.append(req)
|
|
227
|
+
|
|
228
|
+
return active_reqs
|
|
229
|
+
|
|
230
|
+
def _should_ignore_filepath(
|
|
231
|
+
self,
|
|
232
|
+
filepath: str,
|
|
233
|
+
ignore_patterns: list[dict[str, Any]],
|
|
234
|
+
) -> bool:
|
|
235
|
+
"""
|
|
236
|
+
Check if filepath matches any of the ignore patterns.
|
|
237
|
+
|
|
238
|
+
Only checks filepath-based patterns (filepath, filepath_regex).
|
|
239
|
+
This is used for filtering requirements before merging.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
filepath: Path to the policy file
|
|
243
|
+
ignore_patterns: List of ignore pattern dictionaries
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if filepath matches any pattern
|
|
247
|
+
"""
|
|
248
|
+
for pattern in ignore_patterns:
|
|
249
|
+
# Only check filepath-based patterns
|
|
250
|
+
if "filepath" in pattern or "filepath_regex" in pattern:
|
|
251
|
+
regex_pattern = pattern.get("filepath") or pattern.get("filepath_regex")
|
|
252
|
+
if regex_pattern:
|
|
253
|
+
compiled = compile_and_cache(regex_pattern)
|
|
254
|
+
if compiled and compiled.search(filepath):
|
|
255
|
+
return True
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
def _merge_per_action_override(
|
|
259
|
+
self,
|
|
260
|
+
default_requirements: list[dict[str, Any]],
|
|
261
|
+
all_user_requirements: list[dict[str, Any]],
|
|
262
|
+
active_user_requirements: list[dict[str, Any]],
|
|
263
|
+
) -> list[dict[str, Any]]:
|
|
264
|
+
"""
|
|
265
|
+
Merge user requirements with defaults on a per-action basis.
|
|
266
|
+
|
|
267
|
+
User requirements override defaults for matching actions.
|
|
268
|
+
Defaults are kept for actions not specified by user.
|
|
269
|
+
|
|
270
|
+
Key behavior with ignore_patterns:
|
|
271
|
+
- If user defines a requirement for action X with ignore_patterns
|
|
272
|
+
- And the current file matches the ignore_patterns
|
|
273
|
+
- Then: The user requirement is SKIPPED (not applied)
|
|
274
|
+
- AND: The default for action X is ALSO skipped (user "owns" this action)
|
|
275
|
+
- Result: No check for action X on this file
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
default_requirements: Default requirements from system config
|
|
279
|
+
all_user_requirements: ALL user requirements (before ignore_patterns filtering)
|
|
280
|
+
active_user_requirements: User requirements after ignore_patterns filtering
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Merged list of requirements
|
|
284
|
+
"""
|
|
285
|
+
# Build a set of actions that user has customized (from ALL user requirements)
|
|
286
|
+
# This determines which defaults to exclude
|
|
287
|
+
user_actions = set()
|
|
288
|
+
for req in all_user_requirements:
|
|
289
|
+
actions = req.get("actions", [])
|
|
290
|
+
# Handle both single action and list of actions
|
|
291
|
+
if isinstance(actions, str):
|
|
292
|
+
user_actions.add(actions)
|
|
293
|
+
else:
|
|
294
|
+
user_actions.update(actions)
|
|
295
|
+
|
|
296
|
+
# Start with ACTIVE user requirements (filtered by ignore_patterns)
|
|
297
|
+
merged = list(active_user_requirements)
|
|
298
|
+
|
|
299
|
+
# Add defaults that don't conflict with user requirements
|
|
300
|
+
for default_req in default_requirements:
|
|
301
|
+
default_actions = default_req.get("actions", [])
|
|
302
|
+
# Handle both single action and list of actions
|
|
303
|
+
if isinstance(default_actions, str):
|
|
304
|
+
default_actions = [default_actions]
|
|
305
|
+
|
|
306
|
+
# Check if any of the default actions are customized by user
|
|
307
|
+
has_overlap = any(action in user_actions for action in default_actions)
|
|
308
|
+
|
|
309
|
+
if not has_overlap:
|
|
310
|
+
# No overlap - keep this default requirement
|
|
311
|
+
merged.append(default_req)
|
|
312
|
+
|
|
313
|
+
return merged
|
|
314
|
+
|
|
315
|
+
def _filter_requirement_issues(
|
|
316
|
+
self,
|
|
317
|
+
issues: list[ValidationIssue],
|
|
318
|
+
ignore_patterns: list[dict[str, Any]],
|
|
319
|
+
filepath: str,
|
|
320
|
+
) -> list[ValidationIssue]:
|
|
321
|
+
"""
|
|
322
|
+
Filter issues based on requirement-level ignore patterns.
|
|
323
|
+
|
|
324
|
+
This allows each requirement within action_condition_enforcement to have its own
|
|
325
|
+
ignore patterns, enabling fine-grained control over which findings to suppress.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
issues: List of validation issues to filter
|
|
329
|
+
ignore_patterns: List of ignore pattern dictionaries for this requirement
|
|
330
|
+
filepath: Path to the policy file being checked
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Filtered list of issues (issues matching ignore patterns are removed)
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
A requirement can ignore specific files while other requirements check them:
|
|
337
|
+
- actions: ["iam:CreateRole"]
|
|
338
|
+
required_conditions: [...]
|
|
339
|
+
ignore_patterns:
|
|
340
|
+
- filepath_regex: ".*modules/iam-openid.*"
|
|
341
|
+
"""
|
|
342
|
+
if not ignore_patterns:
|
|
343
|
+
return issues
|
|
344
|
+
|
|
345
|
+
return [
|
|
346
|
+
issue
|
|
347
|
+
for issue in issues
|
|
348
|
+
if not IgnorePatternMatcher.should_ignore_issue(issue, filepath, ignore_patterns)
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
async def _check_policy_wide(
|
|
352
|
+
self,
|
|
353
|
+
policy: "IAMPolicy",
|
|
354
|
+
requirement: dict[str, Any],
|
|
355
|
+
fetcher: AWSServiceFetcher,
|
|
356
|
+
config: CheckConfig,
|
|
357
|
+
) -> list[ValidationIssue]:
|
|
358
|
+
"""
|
|
359
|
+
Check actions across the entire policy using all_of/any_of/none_of logic.
|
|
360
|
+
|
|
361
|
+
This enables policy-wide detection patterns:
|
|
362
|
+
- all_of: ALL required actions must exist somewhere in the policy
|
|
363
|
+
- any_of: At least ONE required action must exist somewhere in the policy
|
|
364
|
+
- none_of: NONE of the forbidden actions should exist in the policy
|
|
365
|
+
"""
|
|
366
|
+
issues = []
|
|
367
|
+
actions_config = requirement.get("actions", {})
|
|
368
|
+
all_of = actions_config.get("all_of", [])
|
|
369
|
+
any_of = actions_config.get("any_of", [])
|
|
370
|
+
none_of = actions_config.get("none_of", [])
|
|
371
|
+
|
|
372
|
+
# Collect all actions across the entire policy
|
|
373
|
+
policy_wide_actions: set[str] = set()
|
|
374
|
+
statements_by_action: dict[str, list[tuple[int, Statement]]] = {}
|
|
375
|
+
|
|
376
|
+
for idx, statement in enumerate(policy.statement or []):
|
|
377
|
+
if statement.effect != "Allow":
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
statement_actions = statement.get_actions()
|
|
381
|
+
policy_wide_actions.update(statement_actions)
|
|
382
|
+
|
|
383
|
+
# Track which statements grant which actions
|
|
384
|
+
for action in statement_actions:
|
|
385
|
+
if action not in statements_by_action:
|
|
386
|
+
statements_by_action[action] = []
|
|
387
|
+
statements_by_action[action].append((idx, statement))
|
|
388
|
+
|
|
389
|
+
# Check all_of: ALL required actions must exist in policy
|
|
390
|
+
if all_of:
|
|
391
|
+
all_of_result = await self._check_all_of_policy_wide(
|
|
392
|
+
all_of,
|
|
393
|
+
policy_wide_actions,
|
|
394
|
+
statements_by_action,
|
|
395
|
+
requirement,
|
|
396
|
+
fetcher,
|
|
397
|
+
config,
|
|
398
|
+
)
|
|
399
|
+
issues.extend(all_of_result)
|
|
400
|
+
|
|
401
|
+
# Check any_of: At least ONE required action must exist in policy
|
|
402
|
+
if any_of:
|
|
403
|
+
any_of_result = await self._check_any_of_policy_wide(
|
|
404
|
+
any_of,
|
|
405
|
+
policy_wide_actions,
|
|
406
|
+
statements_by_action,
|
|
407
|
+
requirement,
|
|
408
|
+
fetcher,
|
|
409
|
+
config,
|
|
410
|
+
)
|
|
411
|
+
issues.extend(any_of_result)
|
|
412
|
+
|
|
413
|
+
# Check none_of: NONE of the forbidden actions should exist in policy
|
|
414
|
+
if none_of:
|
|
415
|
+
none_of_result = await self._check_none_of_policy_wide(
|
|
416
|
+
none_of,
|
|
417
|
+
policy_wide_actions,
|
|
418
|
+
statements_by_action,
|
|
419
|
+
requirement,
|
|
420
|
+
config,
|
|
421
|
+
fetcher,
|
|
422
|
+
)
|
|
423
|
+
issues.extend(none_of_result)
|
|
424
|
+
|
|
425
|
+
return issues
|
|
426
|
+
|
|
427
|
+
async def _check_all_of_policy_wide(
|
|
428
|
+
self,
|
|
429
|
+
all_of_actions: list[str],
|
|
430
|
+
policy_wide_actions: set[str],
|
|
431
|
+
statements_by_action: dict[str, list[tuple[int, Statement]]],
|
|
432
|
+
requirement: dict[str, Any],
|
|
433
|
+
fetcher: AWSServiceFetcher,
|
|
434
|
+
config: CheckConfig,
|
|
435
|
+
) -> list[ValidationIssue]:
|
|
436
|
+
"""
|
|
437
|
+
Check if ALL required actions exist anywhere in the policy.
|
|
438
|
+
|
|
439
|
+
For all_of, we report ONLY statements that contain ALL the required actions,
|
|
440
|
+
not statements that contain just some of them. This is useful for detecting
|
|
441
|
+
overly permissive individual statements.
|
|
442
|
+
"""
|
|
443
|
+
issues = []
|
|
444
|
+
|
|
445
|
+
# First, check if ALL required actions exist somewhere in the policy
|
|
446
|
+
found_actions_mapping: dict[str, str] = {} # req_action -> matched_policy_action
|
|
447
|
+
missing_actions: list[str] = []
|
|
448
|
+
|
|
449
|
+
for req_action in all_of_actions:
|
|
450
|
+
action_found = False
|
|
451
|
+
for policy_action in policy_wide_actions:
|
|
452
|
+
if await self._action_matches(
|
|
453
|
+
policy_action, req_action, requirement.get("action_patterns", []), fetcher
|
|
454
|
+
):
|
|
455
|
+
action_found = True
|
|
456
|
+
found_actions_mapping[req_action] = policy_action
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
if not action_found:
|
|
460
|
+
missing_actions.append(req_action)
|
|
461
|
+
|
|
462
|
+
# If not all actions exist in the policy, no issue
|
|
463
|
+
if missing_actions:
|
|
464
|
+
return issues
|
|
465
|
+
|
|
466
|
+
# ALL required actions exist in the policy
|
|
467
|
+
# Now find statements that have ALL of them (not just some)
|
|
468
|
+
statements_with_all_actions: list[tuple[int, Statement, list[str]]] = []
|
|
469
|
+
|
|
470
|
+
# Check each statement to see if it contains ALL required actions
|
|
471
|
+
for statement in statements_by_action.get(list(found_actions_mapping.values())[0], []):
|
|
472
|
+
stmt_idx, stmt = statement
|
|
473
|
+
stmt_actions = stmt.get_actions()
|
|
474
|
+
|
|
475
|
+
# Check if this statement has ALL required actions
|
|
476
|
+
has_all_actions = True
|
|
477
|
+
matched_actions = []
|
|
478
|
+
|
|
479
|
+
for req_action in all_of_actions:
|
|
480
|
+
req_action_found = False
|
|
481
|
+
for stmt_action in stmt_actions:
|
|
482
|
+
if await self._action_matches(
|
|
483
|
+
stmt_action, req_action, requirement.get("action_patterns", []), fetcher
|
|
484
|
+
):
|
|
485
|
+
req_action_found = True
|
|
486
|
+
if stmt_action not in matched_actions:
|
|
487
|
+
matched_actions.append(stmt_action)
|
|
488
|
+
break
|
|
489
|
+
|
|
490
|
+
if not req_action_found:
|
|
491
|
+
has_all_actions = False
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
if has_all_actions:
|
|
495
|
+
statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
|
|
496
|
+
|
|
497
|
+
# Also check other statements not in the first action's list
|
|
498
|
+
checked_indices = {s[0] for s in statements_with_all_actions}
|
|
499
|
+
for policy_action, stmt_list in statements_by_action.items():
|
|
500
|
+
for stmt_idx, stmt in stmt_list:
|
|
501
|
+
if stmt_idx in checked_indices:
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
stmt_actions = stmt.get_actions()
|
|
505
|
+
|
|
506
|
+
# Check if this statement has ALL required actions
|
|
507
|
+
has_all_actions = True
|
|
508
|
+
matched_actions = []
|
|
509
|
+
|
|
510
|
+
for req_action in all_of_actions:
|
|
511
|
+
req_action_found = False
|
|
512
|
+
for stmt_action in stmt_actions:
|
|
513
|
+
if await self._action_matches(
|
|
514
|
+
stmt_action, req_action, requirement.get("action_patterns", []), fetcher
|
|
515
|
+
):
|
|
516
|
+
req_action_found = True
|
|
517
|
+
if stmt_action not in matched_actions:
|
|
518
|
+
matched_actions.append(stmt_action)
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
if not req_action_found:
|
|
522
|
+
has_all_actions = False
|
|
523
|
+
break
|
|
524
|
+
|
|
525
|
+
if has_all_actions:
|
|
526
|
+
statements_with_all_actions.append((stmt_idx, stmt, matched_actions))
|
|
527
|
+
checked_indices.add(stmt_idx)
|
|
528
|
+
|
|
529
|
+
# If no statements have ALL actions, no issue to report
|
|
530
|
+
if not statements_with_all_actions:
|
|
531
|
+
return issues
|
|
532
|
+
|
|
533
|
+
# Report statements that have ALL the dangerous actions
|
|
534
|
+
return self._generate_policy_wide_issues(
|
|
535
|
+
statements_with_all_actions,
|
|
536
|
+
list(found_actions_mapping.values()),
|
|
537
|
+
requirement,
|
|
538
|
+
config,
|
|
539
|
+
"all_of",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
async def _check_any_of_policy_wide(
|
|
543
|
+
self,
|
|
544
|
+
any_of_actions: list[str],
|
|
545
|
+
policy_wide_actions: set[str],
|
|
546
|
+
statements_by_action: dict[str, list[tuple[int, Statement]]],
|
|
547
|
+
requirement: dict[str, Any],
|
|
548
|
+
fetcher: AWSServiceFetcher,
|
|
549
|
+
config: CheckConfig,
|
|
550
|
+
) -> list[ValidationIssue]:
|
|
551
|
+
"""Check if at least ONE required action exists anywhere in the policy."""
|
|
552
|
+
issues = []
|
|
553
|
+
found_actions: list[str] = []
|
|
554
|
+
statements_with_required_actions: list[tuple[int, Statement, list[str]]] = []
|
|
555
|
+
|
|
556
|
+
for req_action in any_of_actions:
|
|
557
|
+
for policy_action in policy_wide_actions:
|
|
558
|
+
if await self._action_matches(
|
|
559
|
+
policy_action, req_action, requirement.get("action_patterns", []), fetcher
|
|
560
|
+
):
|
|
561
|
+
found_actions.append(policy_action)
|
|
562
|
+
|
|
563
|
+
# Track statements that have this action
|
|
564
|
+
if policy_action in statements_by_action:
|
|
565
|
+
for stmt_idx, stmt in statements_by_action[policy_action]:
|
|
566
|
+
existing = next(
|
|
567
|
+
(s for s in statements_with_required_actions if s[0] == stmt_idx),
|
|
568
|
+
None,
|
|
569
|
+
)
|
|
570
|
+
if existing:
|
|
571
|
+
if policy_action not in existing[2]:
|
|
572
|
+
existing[2].append(policy_action)
|
|
573
|
+
else:
|
|
574
|
+
statements_with_required_actions.append(
|
|
575
|
+
(stmt_idx, stmt, [policy_action])
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# If no actions found, no issue
|
|
579
|
+
if not found_actions:
|
|
580
|
+
return issues
|
|
581
|
+
|
|
582
|
+
# At least one action found - validate conditions
|
|
583
|
+
return self._generate_policy_wide_issues(
|
|
584
|
+
statements_with_required_actions,
|
|
585
|
+
found_actions,
|
|
586
|
+
requirement,
|
|
587
|
+
config,
|
|
588
|
+
"any_of",
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
async def _check_none_of_policy_wide(
|
|
592
|
+
self,
|
|
593
|
+
none_of_actions: list[str],
|
|
594
|
+
policy_wide_actions: set[str],
|
|
595
|
+
statements_by_action: dict[str, list[tuple[int, Statement]]],
|
|
596
|
+
requirement: dict[str, Any],
|
|
597
|
+
config: CheckConfig,
|
|
598
|
+
fetcher: AWSServiceFetcher,
|
|
599
|
+
) -> list[ValidationIssue]:
|
|
600
|
+
"""Check if any forbidden actions exist in the policy."""
|
|
601
|
+
issues = []
|
|
602
|
+
forbidden_found: list[str] = []
|
|
603
|
+
statements_with_forbidden: list[tuple[int, Statement, list[str]]] = []
|
|
604
|
+
|
|
605
|
+
for forbidden_action in none_of_actions:
|
|
606
|
+
for policy_action in policy_wide_actions:
|
|
607
|
+
if await self._action_matches(
|
|
608
|
+
policy_action, forbidden_action, requirement.get("action_patterns", []), fetcher
|
|
609
|
+
):
|
|
610
|
+
forbidden_found.append(policy_action)
|
|
611
|
+
|
|
612
|
+
# Track statements with forbidden actions
|
|
613
|
+
if policy_action in statements_by_action:
|
|
614
|
+
for stmt_idx, stmt in statements_by_action[policy_action]:
|
|
615
|
+
existing = next(
|
|
616
|
+
(s for s in statements_with_forbidden if s[0] == stmt_idx), None
|
|
617
|
+
)
|
|
618
|
+
if existing:
|
|
619
|
+
if policy_action not in existing[2]:
|
|
620
|
+
existing[2].append(policy_action)
|
|
621
|
+
else:
|
|
622
|
+
statements_with_forbidden.append((stmt_idx, stmt, [policy_action]))
|
|
623
|
+
|
|
624
|
+
# If forbidden actions found, create issues
|
|
625
|
+
if not forbidden_found:
|
|
626
|
+
return issues
|
|
627
|
+
|
|
628
|
+
description = requirement.get("description", "These actions should not be used")
|
|
629
|
+
severity = requirement.get("severity", self.get_severity(config))
|
|
630
|
+
|
|
631
|
+
for stmt_idx, stmt, actions in statements_with_forbidden:
|
|
632
|
+
actions_formatted = ", ".join(f"`{a}`" for a in actions)
|
|
633
|
+
statement_refs = [
|
|
634
|
+
f"Statement #{idx + 1}{' (SID: ' + s.sid + ')' if s.sid else ''}"
|
|
635
|
+
for idx, s, _ in statements_with_forbidden
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
issues.append(
|
|
639
|
+
ValidationIssue(
|
|
640
|
+
severity=severity,
|
|
641
|
+
statement_sid=stmt.sid,
|
|
642
|
+
statement_index=stmt_idx,
|
|
643
|
+
issue_type="forbidden_action",
|
|
644
|
+
message=f"Forbidden actions {actions_formatted} found. {description}",
|
|
645
|
+
action=", ".join(actions),
|
|
646
|
+
suggestion=f"Remove these forbidden actions. Found in: {', '.join(statement_refs)}. {description}",
|
|
647
|
+
line_number=stmt.line_number,
|
|
648
|
+
field_name="action",
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
return issues
|
|
653
|
+
|
|
654
|
+
def _generate_policy_wide_issues(
|
|
655
|
+
self,
|
|
656
|
+
statements_with_actions: list[tuple[int, Statement, list[str]]],
|
|
657
|
+
found_actions: list[str],
|
|
658
|
+
requirement: dict[str, Any],
|
|
659
|
+
config: CheckConfig,
|
|
660
|
+
operator_type: str,
|
|
661
|
+
) -> list[ValidationIssue]:
|
|
662
|
+
"""Generate validation issues for policy-wide checks."""
|
|
663
|
+
issues = []
|
|
664
|
+
required_conditions_config = requirement.get("required_conditions", [])
|
|
665
|
+
description = requirement.get("description", "")
|
|
666
|
+
severity = requirement.get("severity", self.get_severity(config))
|
|
667
|
+
|
|
668
|
+
if not required_conditions_config:
|
|
669
|
+
# No conditions specified, just report that actions were found
|
|
670
|
+
all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(set(found_actions)))
|
|
671
|
+
statement_refs = [
|
|
672
|
+
f"Statement #{idx + 1}{' (SID: ' + stmt.sid + ')' if stmt.sid else ''}"
|
|
673
|
+
for idx, stmt, _ in statements_with_actions
|
|
674
|
+
]
|
|
675
|
+
|
|
676
|
+
first_idx, first_stmt, _ = statements_with_actions[0]
|
|
677
|
+
issues.append(
|
|
678
|
+
ValidationIssue(
|
|
679
|
+
severity=severity,
|
|
680
|
+
statement_sid=first_stmt.sid,
|
|
681
|
+
statement_index=first_idx,
|
|
682
|
+
issue_type="action_detected",
|
|
683
|
+
message=f"Actions {all_actions_formatted} found across {len(statements_with_actions)} statement(s) ({operator_type}). {description}",
|
|
684
|
+
action=", ".join(sorted(set(found_actions))),
|
|
685
|
+
suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
|
|
686
|
+
line_number=first_stmt.line_number,
|
|
687
|
+
field_name="action",
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
return issues
|
|
691
|
+
|
|
692
|
+
# Validate conditions for each statement
|
|
693
|
+
for idx, statement, matching_actions in statements_with_actions:
|
|
694
|
+
condition_issues = self._validate_conditions(
|
|
695
|
+
statement,
|
|
696
|
+
idx,
|
|
697
|
+
required_conditions_config,
|
|
698
|
+
matching_actions,
|
|
699
|
+
config,
|
|
700
|
+
requirement,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Add context
|
|
704
|
+
for issue in condition_issues:
|
|
705
|
+
issue.suggestion = (
|
|
706
|
+
f"{issue.suggestion}\n\n"
|
|
707
|
+
f"Note: Found {len(statements_with_actions)} statement(s) with these actions in the policy ({operator_type})."
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
issues.extend(condition_issues)
|
|
711
|
+
|
|
712
|
+
return issues
|
|
713
|
+
|
|
714
|
+
async def _check_per_statement(
|
|
715
|
+
self,
|
|
716
|
+
policy: "IAMPolicy",
|
|
717
|
+
requirement: dict[str, Any],
|
|
718
|
+
fetcher: AWSServiceFetcher,
|
|
719
|
+
config: CheckConfig,
|
|
720
|
+
) -> list[ValidationIssue]:
|
|
721
|
+
"""
|
|
722
|
+
Check each statement individually for matching actions (simple list format).
|
|
723
|
+
|
|
724
|
+
Used when actions are specified as a simple list (not using all_of/any_of/none_of).
|
|
725
|
+
"""
|
|
726
|
+
issues = []
|
|
727
|
+
matching_statements: list[tuple[int, Statement, list[str]]] = []
|
|
728
|
+
|
|
729
|
+
for idx, statement in enumerate(policy.statement or []):
|
|
730
|
+
# Only check Allow statements
|
|
731
|
+
if statement.effect != "Allow":
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
statement_actions = statement.get_actions()
|
|
735
|
+
|
|
736
|
+
# Check if this statement matches the action requirement
|
|
737
|
+
actions_match, matching_actions = await self._check_action_match(
|
|
738
|
+
statement_actions, requirement, fetcher
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
if actions_match and matching_actions:
|
|
742
|
+
matching_statements.append((idx, statement, matching_actions))
|
|
743
|
+
|
|
744
|
+
# If no statements match, skip this requirement
|
|
745
|
+
if not matching_statements:
|
|
746
|
+
return issues
|
|
747
|
+
|
|
748
|
+
# Now validate that ALL matching statements have the required conditions
|
|
749
|
+
required_conditions_config = requirement.get("required_conditions", [])
|
|
750
|
+
if not required_conditions_config:
|
|
751
|
+
# No conditions specified, just report that actions were found
|
|
752
|
+
description = requirement.get("description", "")
|
|
753
|
+
severity = requirement.get("severity", self.get_severity(config))
|
|
754
|
+
|
|
755
|
+
# Create a summary issue for all matching statements
|
|
756
|
+
all_actions = set()
|
|
757
|
+
statement_refs = []
|
|
758
|
+
for idx, stmt, actions in matching_statements:
|
|
759
|
+
all_actions.update(actions)
|
|
760
|
+
sid_info = f" (SID: {stmt.sid})" if stmt.sid else ""
|
|
761
|
+
statement_refs.append(f"Statement #{idx + 1}{sid_info}")
|
|
762
|
+
|
|
763
|
+
# Use the first matching statement's index for the issue
|
|
764
|
+
first_idx, first_stmt, _ = matching_statements[0]
|
|
765
|
+
all_actions_formatted = ", ".join(f"`{a}`" for a in sorted(all_actions))
|
|
766
|
+
|
|
767
|
+
issues.append(
|
|
768
|
+
ValidationIssue(
|
|
769
|
+
severity=severity,
|
|
770
|
+
statement_sid=first_stmt.sid,
|
|
771
|
+
statement_index=first_idx,
|
|
772
|
+
issue_type="action_detected",
|
|
773
|
+
message=f"Actions {all_actions_formatted} found in {len(matching_statements)} statement(s). {description}",
|
|
774
|
+
action=", ".join(sorted(all_actions)),
|
|
775
|
+
suggestion=f"Review these statements: {', '.join(statement_refs)}. {description}",
|
|
776
|
+
line_number=first_stmt.line_number,
|
|
777
|
+
field_name="action",
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
return issues
|
|
781
|
+
|
|
782
|
+
# Validate conditions for each matching statement
|
|
783
|
+
for idx, statement, matching_actions in matching_statements:
|
|
784
|
+
condition_issues = self._validate_conditions(
|
|
785
|
+
statement,
|
|
786
|
+
idx,
|
|
787
|
+
required_conditions_config,
|
|
788
|
+
matching_actions,
|
|
789
|
+
config,
|
|
790
|
+
requirement,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Add context to each issue
|
|
794
|
+
for issue in condition_issues:
|
|
795
|
+
issue.suggestion = (
|
|
796
|
+
f"{issue.suggestion}\n\n"
|
|
797
|
+
f"Note: Found {len(matching_statements)} statement(s) with these actions in the policy."
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
issues.extend(condition_issues)
|
|
801
|
+
|
|
802
|
+
return issues
|
|
803
|
+
|
|
804
|
+
async def _check_action_match(
|
|
805
|
+
self,
|
|
806
|
+
statement_actions: list[str],
|
|
807
|
+
requirement: dict[str, Any],
|
|
808
|
+
fetcher: AWSServiceFetcher,
|
|
809
|
+
) -> tuple[bool, list[str]]:
|
|
810
|
+
"""
|
|
811
|
+
Check if statement actions match the requirement.
|
|
812
|
+
Supports: simple list, all_of, any_of, none_of, and action_patterns.
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
(matches, list_of_matching_actions)
|
|
816
|
+
"""
|
|
817
|
+
actions_config = requirement.get("actions", [])
|
|
818
|
+
action_patterns = requirement.get("action_patterns", [])
|
|
819
|
+
|
|
820
|
+
matching_actions: list[str] = []
|
|
821
|
+
|
|
822
|
+
# Handle simple list format (backward compatibility)
|
|
823
|
+
# Also handle requirements with only action_patterns (when actions is empty list)
|
|
824
|
+
if isinstance(actions_config, list) and (actions_config or action_patterns):
|
|
825
|
+
# Simple list - check if any action matches
|
|
826
|
+
for stmt_action in statement_actions:
|
|
827
|
+
if stmt_action == "*":
|
|
828
|
+
continue
|
|
829
|
+
|
|
830
|
+
# Check if this statement action matches any of the required actions or patterns
|
|
831
|
+
# Use _action_matches which handles wildcards in both statement and config
|
|
832
|
+
matched = False
|
|
833
|
+
|
|
834
|
+
# Check against configured actions
|
|
835
|
+
for required_action in actions_config:
|
|
836
|
+
if await self._action_matches(
|
|
837
|
+
stmt_action, required_action, action_patterns, fetcher
|
|
838
|
+
):
|
|
839
|
+
matched = True
|
|
840
|
+
break
|
|
841
|
+
|
|
842
|
+
# If not matched by actions, check against action_patterns directly
|
|
843
|
+
if not matched and action_patterns:
|
|
844
|
+
# Check if statement action matches any of the patterns
|
|
845
|
+
matched = await self._action_matches(stmt_action, "", action_patterns, fetcher)
|
|
846
|
+
|
|
847
|
+
if matched and stmt_action not in matching_actions:
|
|
848
|
+
matching_actions.append(stmt_action)
|
|
849
|
+
|
|
850
|
+
return len(matching_actions) > 0, matching_actions
|
|
851
|
+
|
|
852
|
+
# Handle all_of/any_of/none_of format
|
|
853
|
+
if isinstance(actions_config, dict):
|
|
854
|
+
all_of = actions_config.get("all_of", [])
|
|
855
|
+
any_of = actions_config.get("any_of", [])
|
|
856
|
+
none_of = actions_config.get("none_of", [])
|
|
857
|
+
|
|
858
|
+
# Check all_of: ALL specified actions must be in statement
|
|
859
|
+
if all_of:
|
|
860
|
+
all_present = True
|
|
861
|
+
for req_action in all_of:
|
|
862
|
+
found = False
|
|
863
|
+
for stmt_action in statement_actions:
|
|
864
|
+
if await self._action_matches(
|
|
865
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
866
|
+
):
|
|
867
|
+
found = True
|
|
868
|
+
break
|
|
869
|
+
if not found:
|
|
870
|
+
all_present = False
|
|
871
|
+
break
|
|
872
|
+
|
|
873
|
+
if not all_present:
|
|
874
|
+
return False, []
|
|
875
|
+
|
|
876
|
+
# Collect matching actions
|
|
877
|
+
for stmt_action in statement_actions:
|
|
878
|
+
for req_action in all_of:
|
|
879
|
+
if await self._action_matches(
|
|
880
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
881
|
+
):
|
|
882
|
+
if stmt_action not in matching_actions:
|
|
883
|
+
matching_actions.append(stmt_action)
|
|
884
|
+
|
|
885
|
+
# Check any_of: At least ONE specified action must be in statement
|
|
886
|
+
if any_of:
|
|
887
|
+
any_present = False
|
|
888
|
+
for stmt_action in statement_actions:
|
|
889
|
+
for req_action in any_of:
|
|
890
|
+
if await self._action_matches(
|
|
891
|
+
stmt_action, req_action, action_patterns, fetcher
|
|
892
|
+
):
|
|
893
|
+
any_present = True
|
|
894
|
+
if stmt_action not in matching_actions:
|
|
895
|
+
matching_actions.append(stmt_action)
|
|
896
|
+
|
|
897
|
+
if not any_present:
|
|
898
|
+
return False, []
|
|
899
|
+
|
|
900
|
+
# Check none_of: NONE of the specified actions should be in statement
|
|
901
|
+
if none_of:
|
|
902
|
+
forbidden_actions = []
|
|
903
|
+
for stmt_action in statement_actions:
|
|
904
|
+
for forbidden_action in none_of:
|
|
905
|
+
if await self._action_matches(
|
|
906
|
+
stmt_action, forbidden_action, action_patterns, fetcher
|
|
907
|
+
):
|
|
908
|
+
forbidden_actions.append(stmt_action)
|
|
909
|
+
|
|
910
|
+
# If forbidden actions are found, this is a match for flagging
|
|
911
|
+
if forbidden_actions:
|
|
912
|
+
return True, forbidden_actions
|
|
913
|
+
|
|
914
|
+
return len(matching_actions) > 0, matching_actions
|
|
915
|
+
|
|
916
|
+
return False, []
|
|
917
|
+
|
|
918
|
+
async def _action_matches(
|
|
919
|
+
self,
|
|
920
|
+
statement_action: str,
|
|
921
|
+
required_action: str,
|
|
922
|
+
patterns: list[str],
|
|
923
|
+
fetcher: AWSServiceFetcher,
|
|
924
|
+
) -> bool:
|
|
925
|
+
"""
|
|
926
|
+
Check if a statement action matches a required action or pattern.
|
|
927
|
+
Supports:
|
|
928
|
+
- Exact matches: "s3:GetObject"
|
|
929
|
+
- AWS wildcards in both statement and required actions: "s3:*", "s3:Get*", "iam:Creat*"
|
|
930
|
+
- Regex patterns: "^s3:Get.*", "^iam:Delete.*"
|
|
931
|
+
|
|
932
|
+
This method handles bidirectional wildcard matching using real AWS actions from the fetcher:
|
|
933
|
+
- statement_action="iam:Create*" matches required_action="iam:CreateUser"
|
|
934
|
+
- statement_action="iam:C*" matches pattern="^iam:Create" (by checking actual AWS actions)
|
|
935
|
+
"""
|
|
936
|
+
if statement_action == "*":
|
|
937
|
+
return False
|
|
938
|
+
|
|
939
|
+
# Exact match
|
|
940
|
+
if statement_action == required_action:
|
|
941
|
+
return True
|
|
942
|
+
|
|
943
|
+
# AWS wildcard match in required_action (e.g., "s3:*", "s3:Get*")
|
|
944
|
+
if "*" in required_action:
|
|
945
|
+
# Convert AWS wildcard to regex and cache compilation
|
|
946
|
+
wildcard_pattern = required_action.replace("*", ".*").replace("?", ".")
|
|
947
|
+
try:
|
|
948
|
+
compiled_pattern = compile_and_cache(f"^{wildcard_pattern}$")
|
|
949
|
+
if compiled_pattern.match(statement_action):
|
|
950
|
+
return True
|
|
951
|
+
except re.error:
|
|
952
|
+
# Invalid regex pattern - skip this match attempt
|
|
953
|
+
pass
|
|
954
|
+
|
|
955
|
+
# AWS wildcard match in statement_action (e.g., "iam:Creat*" in policy)
|
|
956
|
+
# Check if this wildcard would grant access to actions matching our patterns
|
|
957
|
+
if "*" in statement_action:
|
|
958
|
+
# Convert statement wildcard to regex pattern
|
|
959
|
+
stmt_wildcard_pattern = statement_action.replace("*", ".*").replace("?", ".")
|
|
960
|
+
|
|
961
|
+
# Check if statement wildcard overlaps with required action
|
|
962
|
+
if "*" not in required_action:
|
|
963
|
+
# Required action is specific (e.g., "iam:CreateUser")
|
|
964
|
+
# Check if statement wildcard would grant it
|
|
965
|
+
try:
|
|
966
|
+
compiled_pattern = compile_and_cache(f"^{stmt_wildcard_pattern}$")
|
|
967
|
+
if compiled_pattern.match(required_action):
|
|
968
|
+
return True
|
|
969
|
+
except re.error:
|
|
970
|
+
# Invalid regex pattern - skip this match attempt
|
|
971
|
+
pass
|
|
972
|
+
|
|
973
|
+
# Check if statement wildcard overlaps with any of our action patterns
|
|
974
|
+
# Strategy: Use real AWS actions from the fetcher instead of hardcoded guesses
|
|
975
|
+
# For example: "iam:C*" should match pattern "^iam:Create" because:
|
|
976
|
+
# - "iam:C*" grants iam:CreateUser, iam:CreateRole, etc. (from AWS)
|
|
977
|
+
# - "^iam:Create" pattern is meant to catch iam:CreateUser, iam:CreateRole, etc.
|
|
978
|
+
# - Therefore they overlap
|
|
979
|
+
if patterns:
|
|
980
|
+
try:
|
|
981
|
+
# Parse the service from the wildcard action
|
|
982
|
+
service_prefix, _ = fetcher.parse_action(statement_action)
|
|
983
|
+
|
|
984
|
+
# Fetch the real list of actions for this service
|
|
985
|
+
service_detail = await fetcher.fetch_service_by_name(service_prefix)
|
|
986
|
+
available_actions = list(service_detail.actions.keys())
|
|
987
|
+
|
|
988
|
+
# Find which actual AWS actions the wildcard would grant
|
|
989
|
+
_, granted_actions = fetcher.match_wildcard_action(
|
|
990
|
+
statement_action.split(":", 1)[1], # Just the action part (e.g., "C*")
|
|
991
|
+
available_actions,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Check if any of the granted actions match our patterns
|
|
995
|
+
for granted_action in granted_actions:
|
|
996
|
+
full_granted_action = f"{service_prefix}:{granted_action}"
|
|
997
|
+
for pattern in patterns:
|
|
998
|
+
try:
|
|
999
|
+
compiled_pattern = compile_and_cache(pattern)
|
|
1000
|
+
if compiled_pattern.match(full_granted_action):
|
|
1001
|
+
return True
|
|
1002
|
+
except re.error:
|
|
1003
|
+
continue
|
|
1004
|
+
|
|
1005
|
+
except (ValueError, Exception): # pylint: disable=broad-exception-caught
|
|
1006
|
+
# If we can't fetch the service or parse the action, fall back to prefix matching
|
|
1007
|
+
stmt_prefix = statement_action.rstrip("*")
|
|
1008
|
+
for pattern in patterns:
|
|
1009
|
+
try:
|
|
1010
|
+
compiled_pattern = compile_and_cache(pattern)
|
|
1011
|
+
if compiled_pattern.match(stmt_prefix):
|
|
1012
|
+
return True
|
|
1013
|
+
except re.error:
|
|
1014
|
+
continue
|
|
1015
|
+
|
|
1016
|
+
# Regex pattern match (from action_patterns config)
|
|
1017
|
+
for pattern in patterns:
|
|
1018
|
+
try:
|
|
1019
|
+
compiled_pattern = compile_and_cache(pattern)
|
|
1020
|
+
if compiled_pattern.match(statement_action):
|
|
1021
|
+
return True
|
|
1022
|
+
except re.error:
|
|
1023
|
+
continue
|
|
1024
|
+
|
|
1025
|
+
return False
|
|
1026
|
+
|
|
1027
|
+
def _validate_conditions(
|
|
1028
|
+
self,
|
|
1029
|
+
statement: Statement,
|
|
1030
|
+
statement_idx: int,
|
|
1031
|
+
required_conditions_config: Any,
|
|
1032
|
+
matching_actions: list[str],
|
|
1033
|
+
config: CheckConfig,
|
|
1034
|
+
requirement: dict[str, Any] | None = None,
|
|
1035
|
+
) -> list[ValidationIssue]:
|
|
1036
|
+
"""
|
|
1037
|
+
Validate that required conditions are present.
|
|
1038
|
+
Supports: simple list, all_of, any_of formats.
|
|
1039
|
+
Can use per-requirement severity override from requirement['severity'].
|
|
1040
|
+
"""
|
|
1041
|
+
issues: list[ValidationIssue] = []
|
|
1042
|
+
|
|
1043
|
+
# Handle simple list format (backward compatibility)
|
|
1044
|
+
if isinstance(required_conditions_config, list):
|
|
1045
|
+
for condition_requirement in required_conditions_config:
|
|
1046
|
+
if not self._has_condition_requirement(statement, condition_requirement):
|
|
1047
|
+
issues.append(
|
|
1048
|
+
self._create_issue(
|
|
1049
|
+
statement,
|
|
1050
|
+
statement_idx,
|
|
1051
|
+
condition_requirement,
|
|
1052
|
+
matching_actions,
|
|
1053
|
+
config,
|
|
1054
|
+
requirement=requirement,
|
|
1055
|
+
)
|
|
1056
|
+
)
|
|
1057
|
+
return issues
|
|
1058
|
+
|
|
1059
|
+
# Handle all_of/any_of/none_of format
|
|
1060
|
+
if isinstance(required_conditions_config, dict):
|
|
1061
|
+
all_of = required_conditions_config.get("all_of", [])
|
|
1062
|
+
any_of = required_conditions_config.get("any_of", [])
|
|
1063
|
+
none_of = required_conditions_config.get("none_of", [])
|
|
1064
|
+
|
|
1065
|
+
# Validate all_of: ALL conditions must be present
|
|
1066
|
+
if all_of:
|
|
1067
|
+
for condition_requirement in all_of:
|
|
1068
|
+
if not self._has_condition_requirement(statement, condition_requirement):
|
|
1069
|
+
issues.append(
|
|
1070
|
+
self._create_issue(
|
|
1071
|
+
statement,
|
|
1072
|
+
statement_idx,
|
|
1073
|
+
condition_requirement,
|
|
1074
|
+
matching_actions,
|
|
1075
|
+
config,
|
|
1076
|
+
requirement_type="all_of",
|
|
1077
|
+
requirement=requirement,
|
|
1078
|
+
)
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
# Validate any_of: At least ONE condition must be present
|
|
1082
|
+
if any_of:
|
|
1083
|
+
any_present = any(
|
|
1084
|
+
self._has_condition_requirement(statement, cond_req) for cond_req in any_of
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
if not any_present:
|
|
1088
|
+
# Check if requirement has custom message/suggestion/example
|
|
1089
|
+
custom_message = requirement.get("message") if requirement else None
|
|
1090
|
+
custom_suggestion = requirement.get("suggestion") if requirement else None
|
|
1091
|
+
custom_example = requirement.get("example") if requirement else None
|
|
1092
|
+
|
|
1093
|
+
if custom_message:
|
|
1094
|
+
# Use fully custom message/suggestion/example from requirement
|
|
1095
|
+
message = custom_message
|
|
1096
|
+
suggestion = custom_suggestion or ""
|
|
1097
|
+
example = custom_example or ""
|
|
1098
|
+
else:
|
|
1099
|
+
# Generate default message and build suggestion from conditions
|
|
1100
|
+
condition_keys = []
|
|
1101
|
+
for cond in any_of:
|
|
1102
|
+
if "all_of" in cond:
|
|
1103
|
+
# Nested all_of - collect all condition keys
|
|
1104
|
+
nested_keys = [
|
|
1105
|
+
c.get("condition_key", "unknown") for c in cond["all_of"]
|
|
1106
|
+
]
|
|
1107
|
+
condition_keys.append(
|
|
1108
|
+
f"({' + '.join(f'`{k}`' for k in nested_keys)})"
|
|
1109
|
+
)
|
|
1110
|
+
else:
|
|
1111
|
+
# Simple condition
|
|
1112
|
+
condition_keys.append(f"`{cond.get('condition_key', 'unknown')}`")
|
|
1113
|
+
condition_keys_formatted = " OR ".join(condition_keys)
|
|
1114
|
+
matching_actions_formatted = ", ".join(f"`{a}`" for a in matching_actions)
|
|
1115
|
+
|
|
1116
|
+
message = (
|
|
1117
|
+
f"Actions {matching_actions_formatted} require at least ONE of these conditions: "
|
|
1118
|
+
f"{condition_keys_formatted}"
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
# Build suggestion and examples from conditions
|
|
1122
|
+
suggestion, example = self._build_any_of_suggestion(any_of)
|
|
1123
|
+
|
|
1124
|
+
issues.append(
|
|
1125
|
+
ValidationIssue(
|
|
1126
|
+
severity=self.get_severity(config),
|
|
1127
|
+
statement_sid=statement.sid,
|
|
1128
|
+
statement_index=statement_idx,
|
|
1129
|
+
issue_type="missing_required_condition_any_of",
|
|
1130
|
+
message=message,
|
|
1131
|
+
action=", ".join(matching_actions),
|
|
1132
|
+
suggestion=suggestion,
|
|
1133
|
+
example=example if example else None,
|
|
1134
|
+
line_number=statement.line_number,
|
|
1135
|
+
field_name="condition",
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
# Validate none_of: NONE of these conditions should be present
|
|
1140
|
+
if none_of:
|
|
1141
|
+
for condition_requirement in none_of:
|
|
1142
|
+
if self._has_condition_requirement(statement, condition_requirement):
|
|
1143
|
+
issues.append(
|
|
1144
|
+
self._create_none_of_issue(
|
|
1145
|
+
statement,
|
|
1146
|
+
statement_idx,
|
|
1147
|
+
condition_requirement,
|
|
1148
|
+
matching_actions,
|
|
1149
|
+
config,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
return issues
|
|
1154
|
+
|
|
1155
|
+
def _has_condition_requirement(
|
|
1156
|
+
self, statement: Statement, condition_requirement: dict[str, Any]
|
|
1157
|
+
) -> bool:
|
|
1158
|
+
"""Check if statement has the required condition."""
|
|
1159
|
+
condition_key = condition_requirement.get("condition_key")
|
|
1160
|
+
if not condition_key:
|
|
1161
|
+
return True # No condition key specified, skip
|
|
1162
|
+
|
|
1163
|
+
operator = condition_requirement.get("operator")
|
|
1164
|
+
expected_value = condition_requirement.get("expected_value")
|
|
1165
|
+
|
|
1166
|
+
return self._has_condition(statement, condition_key, operator, expected_value)
|
|
1167
|
+
|
|
1168
|
+
def _has_condition(
|
|
1169
|
+
self,
|
|
1170
|
+
statement: Statement,
|
|
1171
|
+
condition_key: str,
|
|
1172
|
+
operator: str | None = None,
|
|
1173
|
+
expected_value: Any = None,
|
|
1174
|
+
) -> bool:
|
|
1175
|
+
"""
|
|
1176
|
+
Check if statement has the specified condition key.
|
|
1177
|
+
|
|
1178
|
+
Args:
|
|
1179
|
+
statement: The IAM policy statement
|
|
1180
|
+
condition_key: The condition key to look for
|
|
1181
|
+
operator: Optional specific operator (e.g., "StringEquals")
|
|
1182
|
+
expected_value: Optional expected value for the condition
|
|
1183
|
+
|
|
1184
|
+
Returns:
|
|
1185
|
+
True if condition is present (and matches expected value if specified)
|
|
1186
|
+
"""
|
|
1187
|
+
if not statement.condition:
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
# If operator specified, only check that operator
|
|
1191
|
+
operators_to_check = [operator] if operator else list(statement.condition.keys())
|
|
1192
|
+
|
|
1193
|
+
# Look through specified condition operators
|
|
1194
|
+
for op in operators_to_check:
|
|
1195
|
+
if op not in statement.condition:
|
|
1196
|
+
continue
|
|
1197
|
+
|
|
1198
|
+
conditions = statement.condition[op]
|
|
1199
|
+
if isinstance(conditions, dict):
|
|
1200
|
+
if condition_key in conditions:
|
|
1201
|
+
# If no expected value specified, just presence is enough
|
|
1202
|
+
if expected_value is None:
|
|
1203
|
+
return True
|
|
1204
|
+
|
|
1205
|
+
# Check if the value matches
|
|
1206
|
+
actual_value = conditions[condition_key]
|
|
1207
|
+
|
|
1208
|
+
# Handle boolean values
|
|
1209
|
+
if isinstance(expected_value, bool):
|
|
1210
|
+
if isinstance(actual_value, bool):
|
|
1211
|
+
return actual_value == expected_value
|
|
1212
|
+
if isinstance(actual_value, str):
|
|
1213
|
+
return actual_value.lower() == str(expected_value).lower()
|
|
1214
|
+
|
|
1215
|
+
# Handle exact matches
|
|
1216
|
+
if actual_value == expected_value:
|
|
1217
|
+
return True
|
|
1218
|
+
|
|
1219
|
+
# Handle list values (actual can be string or list)
|
|
1220
|
+
if isinstance(expected_value, list):
|
|
1221
|
+
if isinstance(actual_value, list):
|
|
1222
|
+
return set(expected_value) == set(actual_value)
|
|
1223
|
+
if actual_value in expected_value:
|
|
1224
|
+
return True
|
|
1225
|
+
|
|
1226
|
+
# Handle string matches for variable references like ${aws:PrincipalTag/owner}
|
|
1227
|
+
if str(actual_value) == str(expected_value):
|
|
1228
|
+
return True
|
|
1229
|
+
|
|
1230
|
+
return False
|
|
1231
|
+
|
|
1232
|
+
def _create_issue(
|
|
1233
|
+
self,
|
|
1234
|
+
statement: Statement,
|
|
1235
|
+
statement_idx: int,
|
|
1236
|
+
condition_requirement: dict[str, Any],
|
|
1237
|
+
matching_actions: list[str],
|
|
1238
|
+
config: CheckConfig,
|
|
1239
|
+
requirement_type: str = "required",
|
|
1240
|
+
requirement: dict[str, Any] | None = None,
|
|
1241
|
+
) -> ValidationIssue:
|
|
1242
|
+
"""Create a validation issue for a missing condition.
|
|
1243
|
+
|
|
1244
|
+
Severity precedence:
|
|
1245
|
+
1. Individual condition requirement's severity (condition_requirement['severity'])
|
|
1246
|
+
2. Parent requirement's severity (requirement['severity'])
|
|
1247
|
+
3. Global check severity (config.severity)
|
|
1248
|
+
"""
|
|
1249
|
+
condition_key = condition_requirement.get("condition_key", "unknown")
|
|
1250
|
+
description = condition_requirement.get("description", "")
|
|
1251
|
+
expected_value = condition_requirement.get("expected_value")
|
|
1252
|
+
example = condition_requirement.get("example", "")
|
|
1253
|
+
operator = condition_requirement.get("operator", "StringEquals")
|
|
1254
|
+
|
|
1255
|
+
message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
|
|
1256
|
+
|
|
1257
|
+
# Determine severity with precedence: condition > requirement > global
|
|
1258
|
+
severity = (
|
|
1259
|
+
condition_requirement.get("severity") # Condition-level override
|
|
1260
|
+
or (requirement.get("severity") if requirement else None) # Requirement-level override
|
|
1261
|
+
or self.get_severity(config) # Global check severity
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
suggestion_text, example_code = self._build_suggestion(
|
|
1265
|
+
condition_key, description, example, expected_value, operator
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
|
|
1269
|
+
return ValidationIssue(
|
|
1270
|
+
severity=severity,
|
|
1271
|
+
statement_sid=statement.sid,
|
|
1272
|
+
statement_index=statement_idx,
|
|
1273
|
+
issue_type="missing_required_condition",
|
|
1274
|
+
message=f"{message_prefix} Action(s) {matching_actions_str} require condition `{condition_key}`",
|
|
1275
|
+
action=", ".join(matching_actions),
|
|
1276
|
+
condition_key=condition_key,
|
|
1277
|
+
suggestion=suggestion_text,
|
|
1278
|
+
example=example_code,
|
|
1279
|
+
line_number=statement.line_number,
|
|
1280
|
+
field_name="condition",
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
def _build_suggestion(
|
|
1284
|
+
self,
|
|
1285
|
+
condition_key: str,
|
|
1286
|
+
description: str,
|
|
1287
|
+
example: str,
|
|
1288
|
+
expected_value: Any = None,
|
|
1289
|
+
operator: str = "StringEquals",
|
|
1290
|
+
) -> tuple[str, str]:
|
|
1291
|
+
"""Build suggestion and example for adding the missing condition.
|
|
1292
|
+
|
|
1293
|
+
Returns:
|
|
1294
|
+
Tuple of (suggestion_text, example_code)
|
|
1295
|
+
"""
|
|
1296
|
+
suggestion = description if description else f"Add condition: `{condition_key}`"
|
|
1297
|
+
|
|
1298
|
+
# Build example based on condition key type
|
|
1299
|
+
if example:
|
|
1300
|
+
example_code = example
|
|
1301
|
+
else:
|
|
1302
|
+
# Auto-generate example
|
|
1303
|
+
example_lines = [f' "{operator}": {{']
|
|
1304
|
+
|
|
1305
|
+
if isinstance(expected_value, list):
|
|
1306
|
+
value_str = (
|
|
1307
|
+
"["
|
|
1308
|
+
+ ", ".join(
|
|
1309
|
+
[
|
|
1310
|
+
f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
|
|
1311
|
+
for v in expected_value
|
|
1312
|
+
]
|
|
1313
|
+
)
|
|
1314
|
+
+ "]"
|
|
1315
|
+
)
|
|
1316
|
+
elif expected_value is not None:
|
|
1317
|
+
# Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
|
|
1318
|
+
if str(expected_value).startswith("${"):
|
|
1319
|
+
value_str = f'"{expected_value}"'
|
|
1320
|
+
elif isinstance(expected_value, bool):
|
|
1321
|
+
value_str = str(expected_value).lower()
|
|
1322
|
+
else:
|
|
1323
|
+
value_str = f'"{expected_value}"'
|
|
1324
|
+
else:
|
|
1325
|
+
value_str = '"<value>"'
|
|
1326
|
+
|
|
1327
|
+
example_lines.append(f' "{condition_key}": {value_str}')
|
|
1328
|
+
example_lines.append(" }")
|
|
1329
|
+
|
|
1330
|
+
example_code = "\n".join(example_lines)
|
|
1331
|
+
|
|
1332
|
+
return suggestion, example_code
|
|
1333
|
+
|
|
1334
|
+
def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> tuple[str, str]:
|
|
1335
|
+
"""Build suggestion and combined examples for any_of conditions.
|
|
1336
|
+
|
|
1337
|
+
Always uses clean formatting without "Option X" prefixes.
|
|
1338
|
+
Uses either:
|
|
1339
|
+
- 'message' field if provided (custom message)
|
|
1340
|
+
- 'description' field if provided (displays as: `condition_key` - description)
|
|
1341
|
+
- Just 'condition_key' if neither message nor description provided
|
|
1342
|
+
|
|
1343
|
+
Returns:
|
|
1344
|
+
Tuple of (suggestion_text, combined_examples)
|
|
1345
|
+
"""
|
|
1346
|
+
suggestions = []
|
|
1347
|
+
examples = []
|
|
1348
|
+
|
|
1349
|
+
suggestions.append("Add at least ONE of these conditions:")
|
|
1350
|
+
|
|
1351
|
+
for cond in any_of_conditions:
|
|
1352
|
+
# Handle nested all_of blocks
|
|
1353
|
+
if "all_of" in cond:
|
|
1354
|
+
# Nested all_of - show all required conditions together
|
|
1355
|
+
all_of_list = cond["all_of"]
|
|
1356
|
+
condition_keys = [c.get("condition_key", "unknown") for c in all_of_list]
|
|
1357
|
+
condition_keys_formatted = " + ".join(f"`{k}`" for k in condition_keys)
|
|
1358
|
+
|
|
1359
|
+
# Check for custom message first
|
|
1360
|
+
custom_message = cond.get("message")
|
|
1361
|
+
if custom_message:
|
|
1362
|
+
suggestions.append(f"\n- {custom_message}")
|
|
1363
|
+
else:
|
|
1364
|
+
# Use description from first condition or combine them
|
|
1365
|
+
descriptions = [
|
|
1366
|
+
c.get("description", "") for c in all_of_list if c.get("description")
|
|
1367
|
+
]
|
|
1368
|
+
if descriptions:
|
|
1369
|
+
suggestions.append(f"\n- {condition_keys_formatted} - {descriptions[0]}")
|
|
1370
|
+
else:
|
|
1371
|
+
suggestions.append(f"\n- {condition_keys_formatted} (both required)")
|
|
1372
|
+
|
|
1373
|
+
# Collect example from first condition that has one
|
|
1374
|
+
for c in all_of_list:
|
|
1375
|
+
if c.get("example"):
|
|
1376
|
+
examples.append(c["example"])
|
|
1377
|
+
break
|
|
1378
|
+
else:
|
|
1379
|
+
# Simple condition - check for message, description, or just condition_key
|
|
1380
|
+
custom_message = cond.get("message")
|
|
1381
|
+
if custom_message:
|
|
1382
|
+
# Use custom message directly
|
|
1383
|
+
suggestions.append(f"\n- {custom_message}")
|
|
1384
|
+
else:
|
|
1385
|
+
# Use description if available
|
|
1386
|
+
condition_key = cond.get("condition_key", "unknown")
|
|
1387
|
+
description = cond.get("description", "")
|
|
1388
|
+
expected_value = cond.get("expected_value")
|
|
1389
|
+
|
|
1390
|
+
if description:
|
|
1391
|
+
# Format: - `condition_key` - description
|
|
1392
|
+
suggestions.append(f"\n- `{condition_key}` - {description}")
|
|
1393
|
+
else:
|
|
1394
|
+
# Format: - `condition_key` (with expected value if present)
|
|
1395
|
+
suggestion_line = f"\n- `{condition_key}`"
|
|
1396
|
+
if expected_value is not None:
|
|
1397
|
+
suggestion_line += f" (value: `{expected_value}`)"
|
|
1398
|
+
suggestions.append(suggestion_line)
|
|
1399
|
+
|
|
1400
|
+
# Collect example if present (no prefix)
|
|
1401
|
+
if cond.get("example"):
|
|
1402
|
+
examples.append(cond["example"])
|
|
1403
|
+
|
|
1404
|
+
suggestion_text = "".join(suggestions)
|
|
1405
|
+
combined_examples = "\n\n".join(examples) if examples else ""
|
|
1406
|
+
|
|
1407
|
+
return suggestion_text, combined_examples
|
|
1408
|
+
|
|
1409
|
+
def _create_none_of_issue(
|
|
1410
|
+
self,
|
|
1411
|
+
statement: Statement,
|
|
1412
|
+
statement_idx: int,
|
|
1413
|
+
condition_requirement: dict[str, Any],
|
|
1414
|
+
matching_actions: list[str],
|
|
1415
|
+
config: CheckConfig,
|
|
1416
|
+
) -> ValidationIssue:
|
|
1417
|
+
"""Create a validation issue for a forbidden condition that is present."""
|
|
1418
|
+
condition_key = condition_requirement.get("condition_key", "unknown")
|
|
1419
|
+
description = condition_requirement.get("description", "")
|
|
1420
|
+
expected_value = condition_requirement.get("expected_value")
|
|
1421
|
+
|
|
1422
|
+
matching_actions_str = ", ".join(f"`{a}`" for a in matching_actions)
|
|
1423
|
+
message = f"FORBIDDEN: Action(s) `{matching_actions_str}` must NOT have condition `{condition_key}`"
|
|
1424
|
+
if expected_value is not None:
|
|
1425
|
+
message += f" with value `{expected_value}`"
|
|
1426
|
+
|
|
1427
|
+
suggestion = f"Remove the `{condition_key}` condition from the statement"
|
|
1428
|
+
if description:
|
|
1429
|
+
suggestion += f". {description}"
|
|
1430
|
+
|
|
1431
|
+
return ValidationIssue(
|
|
1432
|
+
severity=self.get_severity(config),
|
|
1433
|
+
statement_sid=statement.sid,
|
|
1434
|
+
statement_index=statement_idx,
|
|
1435
|
+
issue_type="forbidden_condition_present",
|
|
1436
|
+
message=message,
|
|
1437
|
+
action=", ".join(matching_actions),
|
|
1438
|
+
condition_key=condition_key,
|
|
1439
|
+
suggestion=suggestion,
|
|
1440
|
+
line_number=statement.line_number,
|
|
1441
|
+
field_name="condition",
|
|
1442
|
+
)
|