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,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ARN pattern matching utilities for IAM policy validation.
|
|
3
|
+
|
|
4
|
+
This module provides functions for matching ARN patterns with glob support.
|
|
5
|
+
Portions of this code are derived from or inspired by Parliament's ARN matching
|
|
6
|
+
implementation.
|
|
7
|
+
|
|
8
|
+
Original work Copyright 2019 Duo Security (BSD 3-Clause License)
|
|
9
|
+
Modifications and additions Copyright 2024 (MIT License)
|
|
10
|
+
|
|
11
|
+
Parliament: https://github.com/duo-labs/parliament
|
|
12
|
+
License: https://github.com/duo-labs/parliament/blob/master/LICENSE
|
|
13
|
+
|
|
14
|
+
The is_glob_match() function is adapted from Parliament's implementation.
|
|
15
|
+
See: https://github.com/duo-labs/parliament/issues/36#issuecomment-574001764
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def arn_matches(
|
|
22
|
+
arn_pattern: str,
|
|
23
|
+
arn: str,
|
|
24
|
+
resource_type: str | None = None,
|
|
25
|
+
) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
Check if an ARN matches a pattern with glob support.
|
|
28
|
+
|
|
29
|
+
Both the pattern and ARN can contain wildcards (*). This is useful for
|
|
30
|
+
validating that policy resources match the required format for actions.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
arn_pattern: ARN pattern (e.g., from AWS docs), can have wildcards
|
|
34
|
+
arn: ARN from policy, can have wildcards
|
|
35
|
+
resource_type: Optional resource type (e.g., "bucket", "object") for special handling
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if ARN could match the pattern
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> arn_matches("arn:*:s3:::*/*", "arn:aws:s3:::bucket/key")
|
|
42
|
+
True
|
|
43
|
+
|
|
44
|
+
>>> arn_matches("arn:*:s3:::*/*", "arn:aws:s3:::bucket")
|
|
45
|
+
False
|
|
46
|
+
|
|
47
|
+
>>> # Both can have wildcards
|
|
48
|
+
>>> arn_matches("arn:*:s3:::*/*", "arn:aws:s3:::*personalize*")
|
|
49
|
+
True # Could match "arn:aws:s3:::personalize/file"
|
|
50
|
+
|
|
51
|
+
>>> # Special case: S3 buckets can't have /
|
|
52
|
+
>>> arn_matches("arn:*:s3:::*", "arn:aws:s3:::bucket/key", resource_type="bucket")
|
|
53
|
+
False
|
|
54
|
+
"""
|
|
55
|
+
# Wildcard shortcuts
|
|
56
|
+
if arn_pattern == "*" or arn == "*":
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
# Special case for S3 buckets - no "/" allowed
|
|
60
|
+
if resource_type and "bucket" in resource_type.lower():
|
|
61
|
+
# Strip variables like ${aws:username} before checking
|
|
62
|
+
arn_without_vars = _strip_variables_from_arn(arn)
|
|
63
|
+
if "/" in arn_without_vars:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Parse ARN into parts
|
|
67
|
+
pattern_parts = arn_pattern.split(":")
|
|
68
|
+
arn_parts = arn.split(":")
|
|
69
|
+
|
|
70
|
+
# ARN must have at least 6 parts: arn:partition:service:region:account:resource
|
|
71
|
+
if len(pattern_parts) < 6 or len(arn_parts) < 6:
|
|
72
|
+
# Invalid ARN format
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Match first 5 parts (arn:partition:service:region:account)
|
|
76
|
+
for i in range(5):
|
|
77
|
+
pattern_part = pattern_parts[i]
|
|
78
|
+
arn_part = arn_parts[i]
|
|
79
|
+
|
|
80
|
+
# Pattern wildcard matches any non-empty value
|
|
81
|
+
if pattern_part == "*" and arn_part != "":
|
|
82
|
+
continue
|
|
83
|
+
# ARN wildcard matches anything
|
|
84
|
+
elif arn_part == "*":
|
|
85
|
+
continue
|
|
86
|
+
# Exact match
|
|
87
|
+
elif pattern_part == arn_part:
|
|
88
|
+
continue
|
|
89
|
+
else:
|
|
90
|
+
# No match
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# Match resource ID (everything after 5th colon)
|
|
94
|
+
pattern_id = ":".join(pattern_parts[5:])
|
|
95
|
+
arn_id = ":".join(arn_parts[5:])
|
|
96
|
+
|
|
97
|
+
# Replace variables like [key] with wildcard
|
|
98
|
+
arn_id = re.sub(r"\[.+?\]", "*", arn_id)
|
|
99
|
+
|
|
100
|
+
return is_glob_match(pattern_id, arn_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def arn_strictly_valid(
|
|
104
|
+
arn_pattern: str,
|
|
105
|
+
arn: str,
|
|
106
|
+
resource_type: str | None = None,
|
|
107
|
+
) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Strictly validate ARN against pattern with resource type checking.
|
|
110
|
+
|
|
111
|
+
This is stricter than arn_matches() and enforces:
|
|
112
|
+
- Resource type must be present and match
|
|
113
|
+
- No wildcards in resource type portion
|
|
114
|
+
- No extra colons in resource ID
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
arn_pattern: ARN pattern from AWS service definition
|
|
118
|
+
arn: ARN from policy
|
|
119
|
+
resource_type: Optional resource type for additional validation
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if ARN strictly matches the pattern
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
>>> # Valid: has resource type "user"
|
|
126
|
+
>>> arn_strictly_valid("arn:*:iam::*:user/*", "arn:aws:iam::123456789012:user/alice")
|
|
127
|
+
True
|
|
128
|
+
|
|
129
|
+
>>> # Invalid: missing resource type
|
|
130
|
+
>>> arn_strictly_valid("arn:*:iam::*:user/*", "arn:aws:iam::123456789012:u*")
|
|
131
|
+
False
|
|
132
|
+
"""
|
|
133
|
+
# First check basic match
|
|
134
|
+
if not arn_matches(arn_pattern, arn, resource_type):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
# Parse ARNs
|
|
138
|
+
pattern_parts = arn_pattern.split(":")
|
|
139
|
+
arn_parts = arn.split(":")
|
|
140
|
+
|
|
141
|
+
pattern_id = ":".join(pattern_parts[5:])
|
|
142
|
+
arn_id = ":".join(arn_parts[5:])
|
|
143
|
+
|
|
144
|
+
# Check if pattern has a resource type component
|
|
145
|
+
# Example: "user/alice" has resource type "user"
|
|
146
|
+
# Regex: resource type word followed by : or / (excluding patterns starting with *)
|
|
147
|
+
resource_type_match = re.match(r"(^[^\*][\w-]+)[\/\:](.+)", pattern_id)
|
|
148
|
+
|
|
149
|
+
if resource_type_match and arn_id != "*":
|
|
150
|
+
expected_resource_type = resource_type_match.group(1)
|
|
151
|
+
|
|
152
|
+
# ARN must start with the same resource type
|
|
153
|
+
# Invalid: arn:aws:iam::123456789012:u* (wildcards not allowed in resource type)
|
|
154
|
+
if not arn_id.startswith(expected_resource_type):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# Check for invalid colons in resource ID
|
|
158
|
+
# Strip variables first
|
|
159
|
+
arn_id_without_vars = _strip_variables_from_arn(arn_id)
|
|
160
|
+
|
|
161
|
+
# If ARN has colons but pattern doesn't, it's invalid
|
|
162
|
+
if ":" in arn_id_without_vars and ":" not in pattern_id:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_glob_match(s1: str, s2: str) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Recursive glob pattern matching for two strings.
|
|
171
|
+
|
|
172
|
+
Both strings can contain wildcards (*). This implements a recursive
|
|
173
|
+
algorithm that handles all combinations of wildcard positions.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
s1: First string (can contain *)
|
|
177
|
+
s2: Second string (can contain *)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if strings could match
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
>>> is_glob_match("*/*", "*personalize*")
|
|
184
|
+
True
|
|
185
|
+
|
|
186
|
+
>>> is_glob_match("*/*", "mybucket")
|
|
187
|
+
False
|
|
188
|
+
|
|
189
|
+
>>> is_glob_match("*mybucket", "*myotherthing")
|
|
190
|
+
False
|
|
191
|
+
|
|
192
|
+
>>> is_glob_match("test*", "test123")
|
|
193
|
+
True
|
|
194
|
+
|
|
195
|
+
Note:
|
|
196
|
+
This is adapted from Parliament's implementation:
|
|
197
|
+
https://github.com/duo-labs/parliament/issues/36#issuecomment-574001764
|
|
198
|
+
"""
|
|
199
|
+
# If strings are equal, TRUE
|
|
200
|
+
if s1 == s2:
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
# If either string is all wildcards, TRUE
|
|
204
|
+
if (s1 and all(c == "*" for c in s1)) or (s2 and all(c == "*" for c in s2)):
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
# If either string is empty, FALSE (already handled both empty above)
|
|
208
|
+
if not s1 or not s2:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
# At this point, both strings are non-empty
|
|
212
|
+
# If both start with *, TRUE if match first with remainder of second
|
|
213
|
+
# or second with remainder of first
|
|
214
|
+
if s1[0] == s2[0] == "*":
|
|
215
|
+
return is_glob_match(s1[1:], s2) or is_glob_match(s1, s2[1:])
|
|
216
|
+
|
|
217
|
+
# If s1 starts with *, TRUE if remainder of s1 matches any suffix of s2
|
|
218
|
+
if s1[0] == "*":
|
|
219
|
+
return any(is_glob_match(s1[1:], s2[i:]) for i in range(len(s2) + 1))
|
|
220
|
+
|
|
221
|
+
# If s2 starts with *, TRUE if remainder of s2 matches any suffix of s1
|
|
222
|
+
if s2[0] == "*":
|
|
223
|
+
return any(is_glob_match(s1[i:], s2[1:]) for i in range(len(s1) + 1))
|
|
224
|
+
|
|
225
|
+
# TRUE if both have same first character and remainders match
|
|
226
|
+
return s1[0] == s2[0] and is_glob_match(s1[1:], s2[1:])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _strip_variables_from_arn(arn: str, replace_with: str = "") -> str:
|
|
230
|
+
"""
|
|
231
|
+
Strip AWS policy variables from ARN.
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
${aws:username} → ""
|
|
235
|
+
bucket-${aws:username} → "bucket-"
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
arn: ARN string that may contain variables
|
|
239
|
+
replace_with: What to replace variables with (default: empty string)
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
ARN with variables replaced
|
|
243
|
+
"""
|
|
244
|
+
# Match ${aws.whatever} or ${aws:whatever}
|
|
245
|
+
return re.sub(r"\$\{aws[\.:][\w\/]+\}", replace_with, arn)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def normalize_template_variables(arn: str) -> str:
|
|
249
|
+
"""
|
|
250
|
+
Normalize template variables in ARN to valid placeholders for validation.
|
|
251
|
+
|
|
252
|
+
This function is POSITION-AWARE and handles ANY variable name by determining
|
|
253
|
+
the appropriate replacement based on where the variable appears in the ARN structure.
|
|
254
|
+
It correctly handles variables with colons inside them (e.g., ${AWS::AccountId}).
|
|
255
|
+
|
|
256
|
+
Supports template variables from:
|
|
257
|
+
- Terraform/Terragrunt: ${var.name}, ${local.value}, ${data.source.attr}, etc.
|
|
258
|
+
- CloudFormation: ${AWS::AccountId}, ${AWS::Region}, ${MyParameter}, etc.
|
|
259
|
+
- AWS policy variables: ${aws:username}, ${aws:PrincipalTag/tag-key}, etc.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
arn: ARN string that may contain template variables
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
ARN with template variables replaced with valid placeholders based on position
|
|
266
|
+
|
|
267
|
+
Examples:
|
|
268
|
+
>>> normalize_template_variables("arn:aws:iam::${my_account}:role/name")
|
|
269
|
+
'arn:aws:iam::123456789012:role/name'
|
|
270
|
+
|
|
271
|
+
>>> normalize_template_variables("arn:aws:iam::${AWS::AccountId}:role/name")
|
|
272
|
+
'arn:aws:iam::123456789012:role/name'
|
|
273
|
+
|
|
274
|
+
>>> normalize_template_variables("arn:${var.partition}:s3:::${var.bucket}/*")
|
|
275
|
+
'arn:aws:s3:::placeholder/*'
|
|
276
|
+
"""
|
|
277
|
+
# Strategy: Use a simpler, more robust approach
|
|
278
|
+
# First protect template variables by temporarily replacing them with markers,
|
|
279
|
+
# then split the ARN, then replace based on position
|
|
280
|
+
|
|
281
|
+
# Step 1: Find all template variables and temporarily replace them with position markers
|
|
282
|
+
# This handles variables with colons inside them (like ${AWS::AccountId})
|
|
283
|
+
variables = []
|
|
284
|
+
|
|
285
|
+
def save_variable(match):
|
|
286
|
+
variables.append(match.group(0))
|
|
287
|
+
return f"__VAR{len(variables) - 1}__"
|
|
288
|
+
|
|
289
|
+
# Save all template variables (including those with colons, dots, slashes, etc.)
|
|
290
|
+
temp_arn = re.sub(r"\$\{[^}]+\}", save_variable, arn)
|
|
291
|
+
|
|
292
|
+
# Step 2: Now we can safely split by colons
|
|
293
|
+
parts = temp_arn.split(":", 5)
|
|
294
|
+
|
|
295
|
+
if len(parts) < 6:
|
|
296
|
+
# Not a valid ARN format, restore variables with generic placeholder
|
|
297
|
+
result = arn
|
|
298
|
+
for var in variables:
|
|
299
|
+
if re.match(r"\$\{aws[\.:]", var, re.IGNORECASE):
|
|
300
|
+
result = result.replace(var, "placeholder", 1)
|
|
301
|
+
else:
|
|
302
|
+
result = result.replace(var, "placeholder", 1)
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
# Step 3: Restore variables based on their position in the ARN
|
|
306
|
+
# ARN format: arn:partition:service:region:account:resource
|
|
307
|
+
replacements = {
|
|
308
|
+
1: "aws", # partition
|
|
309
|
+
2: "s3", # service (generic placeholder)
|
|
310
|
+
3: "us-east-1", # region
|
|
311
|
+
4: "123456789012", # account
|
|
312
|
+
5: "placeholder", # resource
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for i, part in enumerate(parts):
|
|
316
|
+
if "__VAR" in part:
|
|
317
|
+
# Find all variable markers in this part
|
|
318
|
+
for j, var in enumerate(variables):
|
|
319
|
+
marker = f"__VAR{j}__"
|
|
320
|
+
if marker in part:
|
|
321
|
+
# Determine replacement based on position
|
|
322
|
+
if i in replacements:
|
|
323
|
+
parts[i] = parts[i].replace(marker, replacements[i])
|
|
324
|
+
else:
|
|
325
|
+
parts[i] = parts[i].replace(marker, "placeholder")
|
|
326
|
+
|
|
327
|
+
# Reconstruct ARN
|
|
328
|
+
return ":".join(parts)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def has_template_variables(arn: str) -> bool:
|
|
332
|
+
"""
|
|
333
|
+
Check if an ARN contains template variables.
|
|
334
|
+
|
|
335
|
+
Detects template variables from:
|
|
336
|
+
- Terraform/Terragrunt: ${var_name}
|
|
337
|
+
- CloudFormation: ${AWS::AccountId}
|
|
338
|
+
- AWS policy variables: ${aws:username}
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
arn: ARN string to check
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True if ARN contains template variables, False otherwise
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
>>> has_template_variables("arn:aws:iam::${aws_account_id}:role/name")
|
|
348
|
+
True
|
|
349
|
+
|
|
350
|
+
>>> has_template_variables("arn:aws:iam::123456789012:role/name")
|
|
351
|
+
False
|
|
352
|
+
"""
|
|
353
|
+
return bool(re.search(r"\$\{[\w\-\.\_:\/]+\}", arn))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def convert_aws_pattern_to_wildcard(pattern: str) -> str:
|
|
357
|
+
"""
|
|
358
|
+
Convert AWS ARN pattern format to wildcard pattern for matching.
|
|
359
|
+
|
|
360
|
+
AWS provides ARN patterns with placeholders like ${Partition}, ${BucketName},
|
|
361
|
+
etc. This function converts them to wildcard (*) patterns that can be used
|
|
362
|
+
with arn_matches() and arn_strictly_valid().
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
pattern: ARN pattern from AWS service definition
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
ARN pattern with placeholders replaced by wildcards
|
|
369
|
+
|
|
370
|
+
Examples:
|
|
371
|
+
>>> convert_aws_pattern_to_wildcard("arn:${Partition}:s3:::${BucketName}/${ObjectName}")
|
|
372
|
+
"arn:*:s3:::*/*"
|
|
373
|
+
|
|
374
|
+
>>> convert_aws_pattern_to_wildcard("arn:${Partition}:iam::${Account}:user/${UserNameWithPath}")
|
|
375
|
+
"arn:*:iam::*:user/*"
|
|
376
|
+
|
|
377
|
+
>>> convert_aws_pattern_to_wildcard("arn:${Partition}:ec2:${Region}:${Account}:instance/${InstanceId}")
|
|
378
|
+
"arn:*:ec2:*:*:instance/*"
|
|
379
|
+
"""
|
|
380
|
+
# Replace all ${...} placeholders with *
|
|
381
|
+
# Matches ${Partition}, ${BucketName}, ${Account}, etc.
|
|
382
|
+
return re.sub(r"\$\{[^}]+\}", "*", pattern)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context managers for common validation workflows.
|
|
3
|
+
|
|
4
|
+
This module provides context managers that handle resource lifecycle
|
|
5
|
+
and make the validation API more convenient to use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
12
|
+
from iam_validator.core.models import PolicyValidationResult
|
|
13
|
+
from iam_validator.core.policy_checks import validate_policies
|
|
14
|
+
from iam_validator.core.policy_loader import PolicyLoader
|
|
15
|
+
from iam_validator.core.report import ReportGenerator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValidationContext:
|
|
19
|
+
"""
|
|
20
|
+
Validation context that provides convenience methods with shared resources.
|
|
21
|
+
|
|
22
|
+
This class maintains a shared AWSServiceFetcher and configuration
|
|
23
|
+
across multiple validation operations, improving performance.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
fetcher: AWSServiceFetcher,
|
|
29
|
+
config_path: str | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize validation context.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
fetcher: AWS service fetcher instance
|
|
36
|
+
config_path: Optional path to configuration file
|
|
37
|
+
"""
|
|
38
|
+
self.fetcher = fetcher
|
|
39
|
+
self.config_path = config_path
|
|
40
|
+
self.loader = PolicyLoader()
|
|
41
|
+
|
|
42
|
+
async def validate_file(self, file_path: str | Path) -> PolicyValidationResult:
|
|
43
|
+
"""
|
|
44
|
+
Validate a single IAM policy file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
file_path: Path to the policy file
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
PolicyValidationResult for the policy
|
|
51
|
+
"""
|
|
52
|
+
policies = self.loader.load_from_path(str(file_path))
|
|
53
|
+
|
|
54
|
+
if not policies:
|
|
55
|
+
raise ValueError(f"No IAM policies found in {file_path}")
|
|
56
|
+
|
|
57
|
+
results = await validate_policies(
|
|
58
|
+
policies,
|
|
59
|
+
config_path=self.config_path,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
results[0]
|
|
64
|
+
if results
|
|
65
|
+
else PolicyValidationResult(
|
|
66
|
+
policy_file=str(file_path),
|
|
67
|
+
is_valid=False,
|
|
68
|
+
issues=[],
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def validate_directory(self, dir_path: str | Path) -> list[PolicyValidationResult]:
|
|
73
|
+
"""
|
|
74
|
+
Validate all IAM policies in a directory.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
dir_path: Path to directory containing policy files
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of PolicyValidationResults for all policies found
|
|
81
|
+
"""
|
|
82
|
+
policies = self.loader.load_from_path(str(dir_path))
|
|
83
|
+
|
|
84
|
+
if not policies:
|
|
85
|
+
raise ValueError(f"No IAM policies found in {dir_path}")
|
|
86
|
+
|
|
87
|
+
return await validate_policies(
|
|
88
|
+
policies,
|
|
89
|
+
config_path=self.config_path,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def validate_json(
|
|
93
|
+
self, policy_json: dict, policy_name: str = "inline-policy"
|
|
94
|
+
) -> PolicyValidationResult:
|
|
95
|
+
"""
|
|
96
|
+
Validate an IAM policy from a Python dictionary.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
policy_json: IAM policy as a Python dict
|
|
100
|
+
policy_name: Name to identify this policy in results
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
PolicyValidationResult for the policy
|
|
104
|
+
"""
|
|
105
|
+
from iam_validator.core.models import IAMPolicy
|
|
106
|
+
|
|
107
|
+
# Parse the dict into an IAMPolicy
|
|
108
|
+
policy = IAMPolicy(**policy_json)
|
|
109
|
+
|
|
110
|
+
results = await validate_policies(
|
|
111
|
+
[(policy_name, policy)],
|
|
112
|
+
config_path=self.config_path,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
results[0]
|
|
117
|
+
if results
|
|
118
|
+
else PolicyValidationResult(
|
|
119
|
+
policy_file=policy_name,
|
|
120
|
+
is_valid=False,
|
|
121
|
+
issues=[],
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def generate_report(
|
|
126
|
+
self, results: list[PolicyValidationResult], format: str = "console"
|
|
127
|
+
) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Generate a report from validation results.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
results: List of validation results
|
|
133
|
+
format: Output format (console, json, html, csv, markdown, sarif)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Formatted report as string
|
|
137
|
+
"""
|
|
138
|
+
generator = ReportGenerator()
|
|
139
|
+
report = generator.generate_report(results)
|
|
140
|
+
|
|
141
|
+
if format == "console":
|
|
142
|
+
# Return empty string for console (it prints directly)
|
|
143
|
+
generator.print_console_report(report)
|
|
144
|
+
return ""
|
|
145
|
+
elif format == "json":
|
|
146
|
+
from iam_validator.core.formatters.json import JSONFormatter
|
|
147
|
+
|
|
148
|
+
return JSONFormatter().format(report)
|
|
149
|
+
elif format == "html":
|
|
150
|
+
from iam_validator.core.formatters.html import HTMLFormatter
|
|
151
|
+
|
|
152
|
+
return HTMLFormatter().format(report)
|
|
153
|
+
elif format == "csv":
|
|
154
|
+
from iam_validator.core.formatters.csv import CSVFormatter
|
|
155
|
+
|
|
156
|
+
return CSVFormatter().format(report)
|
|
157
|
+
elif format == "markdown":
|
|
158
|
+
from iam_validator.core.formatters.markdown import MarkdownFormatter
|
|
159
|
+
|
|
160
|
+
return MarkdownFormatter().format(report)
|
|
161
|
+
elif format == "sarif":
|
|
162
|
+
from iam_validator.core.formatters.sarif import SARIFFormatter
|
|
163
|
+
|
|
164
|
+
return SARIFFormatter().format(report)
|
|
165
|
+
else:
|
|
166
|
+
raise ValueError(f"Unknown format: {format}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def validator(
|
|
171
|
+
config_path: str | None = None,
|
|
172
|
+
):
|
|
173
|
+
"""
|
|
174
|
+
Context manager that handles AWS fetcher lifecycle.
|
|
175
|
+
|
|
176
|
+
This context manager creates an AWS service fetcher, provides a validation
|
|
177
|
+
context for performing multiple validations efficiently, and ensures proper
|
|
178
|
+
cleanup when done.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
config_path: Optional path to configuration file
|
|
182
|
+
|
|
183
|
+
Yields:
|
|
184
|
+
ValidationContext for performing validations
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
>>> async with validator() as v:
|
|
188
|
+
... result = await v.validate_file("policy.json")
|
|
189
|
+
... report = v.generate_report([result], format="json")
|
|
190
|
+
...
|
|
191
|
+
... # Can do multiple validations with same context
|
|
192
|
+
... result2 = await v.validate_directory("./policies")
|
|
193
|
+
|
|
194
|
+
Example with configuration:
|
|
195
|
+
>>> async with validator(config_path="./iam-validator.yaml") as v:
|
|
196
|
+
... results = await v.validate_directory("./policies")
|
|
197
|
+
... v.generate_report(results, format="console")
|
|
198
|
+
"""
|
|
199
|
+
fetcher = AWSServiceFetcher()
|
|
200
|
+
yield ValidationContext(fetcher, config_path)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@asynccontextmanager
|
|
204
|
+
async def validator_from_config(config_path: str):
|
|
205
|
+
"""
|
|
206
|
+
Context manager that loads configuration and creates a validator.
|
|
207
|
+
|
|
208
|
+
Convenience wrapper around validator() that loads config from a file.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
config_path: Path to configuration file
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
ValidationContext configured from the config file
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
>>> async with validator_from_config("./iam-validator.yaml") as v:
|
|
218
|
+
... results = await v.validate_directory("./policies")
|
|
219
|
+
... v.generate_report(results)
|
|
220
|
+
"""
|
|
221
|
+
fetcher = AWSServiceFetcher()
|
|
222
|
+
yield ValidationContext(fetcher, config_path=config_path)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public exception types for the IAM Policy Validator SDK.
|
|
3
|
+
|
|
4
|
+
This module defines user-facing exceptions that library users might want to catch
|
|
5
|
+
and handle in their code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IAMValidatorError(Exception):
|
|
10
|
+
"""Base exception for all IAM Validator errors."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PolicyLoadError(IAMValidatorError):
|
|
16
|
+
"""Raised when a policy file cannot be loaded or parsed."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PolicyValidationError(IAMValidatorError):
|
|
22
|
+
"""Raised when policy validation fails critically."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigurationError(IAMValidatorError):
|
|
28
|
+
"""Raised when configuration is invalid or cannot be loaded."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AWSServiceError(IAMValidatorError):
|
|
34
|
+
"""Raised when AWS service data cannot be fetched."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidPolicyFormatError(PolicyLoadError):
|
|
40
|
+
"""Raised when policy format is invalid (not valid JSON/YAML or missing required fields)."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnsupportedPolicyTypeError(PolicyLoadError):
|
|
46
|
+
"""Raised when policy type is not supported."""
|
|
47
|
+
|
|
48
|
+
pass
|