iam-policy-validator 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of iam-policy-validator might be problematic. Click here for more details.
- iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
- iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
- iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.4.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +27 -0
- iam_validator/checks/action_condition_enforcement.py +727 -0
- iam_validator/checks/action_resource_constraint.py +151 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +70 -0
- iam_validator/checks/policy_size.py +151 -0
- iam_validator/checks/policy_type_validation.py +299 -0
- iam_validator/checks/principal_validation.py +282 -0
- iam_validator/checks/resource_validation.py +108 -0
- iam_validator/checks/security_best_practices.py +536 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +434 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +260 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +539 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +666 -0
- iam_validator/core/access_analyzer_report.py +643 -0
- iam_validator/core/aws_fetcher.py +880 -0
- iam_validator/core/aws_global_conditions.py +137 -0
- iam_validator/core/check_registry.py +469 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/config_loader.py +452 -0
- iam_validator/core/defaults.py +393 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +434 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +187 -0
- iam_validator/core/models.py +298 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +338 -0
- iam_validator/core/report.py +859 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +795 -0
- iam_validator/integrations/ms_teams.py +442 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Policy Type Validation Check.
|
|
2
|
+
|
|
3
|
+
This check validates policy-type-specific requirements:
|
|
4
|
+
- Resource policies (RESOURCE_POLICY) must have a Principal element
|
|
5
|
+
- Identity policies (IDENTITY_POLICY) should not have a Principal element
|
|
6
|
+
- Service Control Policies (SERVICE_CONTROL_POLICY) have specific requirements
|
|
7
|
+
- Resource Control Policies (RESOURCE_CONTROL_POLICY) have strict requirements
|
|
8
|
+
|
|
9
|
+
This check runs automatically based on:
|
|
10
|
+
1. The --policy-type flag value
|
|
11
|
+
2. Auto-detection: If any statement has a Principal, provides helpful guidance
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from iam_validator.core.models import IAMPolicy, ValidationIssue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def execute_policy(
|
|
18
|
+
policy: IAMPolicy, policy_file: str, policy_type: str = "IDENTITY_POLICY", **kwargs
|
|
19
|
+
) -> list[ValidationIssue]:
|
|
20
|
+
"""Validate policy-type-specific requirements.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
policy: IAM policy document
|
|
24
|
+
policy_file: Path to policy file
|
|
25
|
+
policy_type: Type of policy (IDENTITY_POLICY, RESOURCE_POLICY, SERVICE_CONTROL_POLICY)
|
|
26
|
+
**kwargs: Additional context (fetcher, statement index, etc.)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of validation issues
|
|
30
|
+
"""
|
|
31
|
+
issues = []
|
|
32
|
+
|
|
33
|
+
# Check if any statement has Principal
|
|
34
|
+
has_any_principal = any(
|
|
35
|
+
stmt.principal is not None or stmt.not_principal is not None for stmt in policy.statement
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# If policy has Principal but type is IDENTITY_POLICY (default), provide helpful info
|
|
39
|
+
if has_any_principal and policy_type == "IDENTITY_POLICY":
|
|
40
|
+
issues.append(
|
|
41
|
+
ValidationIssue(
|
|
42
|
+
severity="info",
|
|
43
|
+
issue_type="policy_type_hint",
|
|
44
|
+
message="Policy contains Principal element - this suggests it's a resource policy. "
|
|
45
|
+
"Use --policy-type RESOURCE_POLICY for proper validation.",
|
|
46
|
+
statement_index=0,
|
|
47
|
+
statement_sid=None,
|
|
48
|
+
line_number=None,
|
|
49
|
+
suggestion="If this is a resource policy (S3 bucket policy, SNS topic policy, etc.), "
|
|
50
|
+
"run validation with: iam-validator validate --path <file> --policy-type RESOURCE_POLICY",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
# Don't run further checks if we're just hinting
|
|
54
|
+
return issues
|
|
55
|
+
|
|
56
|
+
# Resource policies MUST have Principal
|
|
57
|
+
if policy_type == "RESOURCE_POLICY":
|
|
58
|
+
for idx, statement in enumerate(policy.statement):
|
|
59
|
+
has_principal = statement.principal is not None or statement.not_principal is not None
|
|
60
|
+
|
|
61
|
+
if not has_principal:
|
|
62
|
+
issues.append(
|
|
63
|
+
ValidationIssue(
|
|
64
|
+
severity="error",
|
|
65
|
+
issue_type="missing_principal",
|
|
66
|
+
message="Resource policy statement missing required Principal element. "
|
|
67
|
+
"Resource-based policies (S3 bucket policies, SNS topic policies, etc.) "
|
|
68
|
+
"must include a Principal element to specify who can access the resource.",
|
|
69
|
+
statement_index=idx,
|
|
70
|
+
statement_sid=statement.sid,
|
|
71
|
+
line_number=statement.line_number,
|
|
72
|
+
suggestion="Add a Principal element to specify who can access this resource.\n"
|
|
73
|
+
"Example:\n"
|
|
74
|
+
"{\n"
|
|
75
|
+
' "Effect": "Allow",\n'
|
|
76
|
+
' "Principal": {\n'
|
|
77
|
+
' "AWS": "arn:aws:iam::123456789012:root"\n'
|
|
78
|
+
" },\n"
|
|
79
|
+
' "Action": "s3:GetObject",\n'
|
|
80
|
+
' "Resource": "arn:aws:s3:::bucket/*"\n'
|
|
81
|
+
"}",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Identity policies should NOT have Principal (warning, not error)
|
|
86
|
+
elif policy_type == "IDENTITY_POLICY":
|
|
87
|
+
for idx, statement in enumerate(policy.statement):
|
|
88
|
+
has_principal = statement.principal is not None or statement.not_principal is not None
|
|
89
|
+
|
|
90
|
+
if has_principal:
|
|
91
|
+
issues.append(
|
|
92
|
+
ValidationIssue(
|
|
93
|
+
severity="warning",
|
|
94
|
+
issue_type="unexpected_principal",
|
|
95
|
+
message="Identity policy should not contain Principal element. "
|
|
96
|
+
"Identity-based policies (attached to IAM users, groups, or roles) "
|
|
97
|
+
"do not need a Principal element because the principal is implicit "
|
|
98
|
+
"(the entity the policy is attached to).",
|
|
99
|
+
statement_index=idx,
|
|
100
|
+
statement_sid=statement.sid,
|
|
101
|
+
line_number=statement.line_number,
|
|
102
|
+
suggestion="Remove the Principal element from this identity policy statement.\n"
|
|
103
|
+
"Example:\n"
|
|
104
|
+
"{\n"
|
|
105
|
+
' "Effect": "Allow",\n'
|
|
106
|
+
' "Action": "s3:GetObject",\n'
|
|
107
|
+
' "Resource": "arn:aws:s3:::bucket/*"\n'
|
|
108
|
+
"}",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Service Control Policies (SCPs) should not have Principal
|
|
113
|
+
elif policy_type == "SERVICE_CONTROL_POLICY":
|
|
114
|
+
for idx, statement in enumerate(policy.statement):
|
|
115
|
+
has_principal = statement.principal is not None or statement.not_principal is not None
|
|
116
|
+
|
|
117
|
+
if has_principal:
|
|
118
|
+
issues.append(
|
|
119
|
+
ValidationIssue(
|
|
120
|
+
severity="error",
|
|
121
|
+
issue_type="invalid_principal",
|
|
122
|
+
message="Service Control Policy must not contain Principal element. "
|
|
123
|
+
"Service Control Policies (SCPs) in AWS Organizations do not support "
|
|
124
|
+
"the Principal element. They apply to all principals in the organization or OU.",
|
|
125
|
+
statement_index=idx,
|
|
126
|
+
statement_sid=statement.sid,
|
|
127
|
+
line_number=statement.line_number,
|
|
128
|
+
suggestion="Remove the Principal element from this SCP statement.\n"
|
|
129
|
+
"Example:\n"
|
|
130
|
+
"{\n"
|
|
131
|
+
' "Effect": "Deny",\n'
|
|
132
|
+
' "Action": "ec2:*",\n'
|
|
133
|
+
' "Resource": "*",\n'
|
|
134
|
+
' "Condition": {\n'
|
|
135
|
+
' "StringNotEquals": {\n'
|
|
136
|
+
' "ec2:Region": ["us-east-1", "us-west-2"]\n'
|
|
137
|
+
" }\n"
|
|
138
|
+
" }\n"
|
|
139
|
+
"}",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Resource Control Policies (RCPs) have very strict requirements
|
|
144
|
+
elif policy_type == "RESOURCE_CONTROL_POLICY":
|
|
145
|
+
# RCP supported services (as of 2025)
|
|
146
|
+
rcp_supported_services = {"s3", "sts", "sqs", "secretsmanager", "kms"}
|
|
147
|
+
|
|
148
|
+
for idx, statement in enumerate(policy.statement):
|
|
149
|
+
# 1. Effect MUST be Deny (only RCPFullAWSAccess can use Allow)
|
|
150
|
+
if statement.effect and statement.effect.lower() != "deny":
|
|
151
|
+
issues.append(
|
|
152
|
+
ValidationIssue(
|
|
153
|
+
severity="error",
|
|
154
|
+
issue_type="invalid_rcp_effect",
|
|
155
|
+
message="Resource Control Policy statement must have Effect: Deny. "
|
|
156
|
+
"For RCPs that you create, the Effect value must be 'Deny'. "
|
|
157
|
+
"Only the AWS-managed RCPFullAWSAccess policy can use 'Allow'.",
|
|
158
|
+
statement_index=idx,
|
|
159
|
+
statement_sid=statement.sid,
|
|
160
|
+
line_number=statement.line_number,
|
|
161
|
+
suggestion='Change the Effect to "Deny" for this RCP statement.',
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# 2. Principal MUST be "*" (and only "*")
|
|
166
|
+
has_principal = statement.principal is not None
|
|
167
|
+
has_not_principal = statement.not_principal is not None
|
|
168
|
+
|
|
169
|
+
if has_not_principal:
|
|
170
|
+
issues.append(
|
|
171
|
+
ValidationIssue(
|
|
172
|
+
severity="error",
|
|
173
|
+
issue_type="invalid_rcp_not_principal",
|
|
174
|
+
message="Resource Control Policy must not contain NotPrincipal element. "
|
|
175
|
+
"RCPs only support Principal with value '*'. Use Condition elements "
|
|
176
|
+
"to restrict specific principals.",
|
|
177
|
+
statement_index=idx,
|
|
178
|
+
statement_sid=statement.sid,
|
|
179
|
+
line_number=statement.line_number,
|
|
180
|
+
suggestion="Remove NotPrincipal and use Principal: '*' with Condition "
|
|
181
|
+
"elements to restrict access.",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
elif not has_principal:
|
|
185
|
+
issues.append(
|
|
186
|
+
ValidationIssue(
|
|
187
|
+
severity="error",
|
|
188
|
+
issue_type="missing_rcp_principal",
|
|
189
|
+
message="Resource Control Policy statement must have Principal: '*'. "
|
|
190
|
+
"RCPs require the Principal element with value '*'. Use Condition "
|
|
191
|
+
"elements to restrict specific principals.",
|
|
192
|
+
statement_index=idx,
|
|
193
|
+
statement_sid=statement.sid,
|
|
194
|
+
line_number=statement.line_number,
|
|
195
|
+
suggestion='Add Principal: "*" to this RCP statement.',
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
elif statement.principal != "*":
|
|
199
|
+
# Check if it's the dict format {"AWS": "*"} or other variations
|
|
200
|
+
principal_str = str(statement.principal)
|
|
201
|
+
if principal_str != "*":
|
|
202
|
+
issues.append(
|
|
203
|
+
ValidationIssue(
|
|
204
|
+
severity="error",
|
|
205
|
+
issue_type="invalid_rcp_principal",
|
|
206
|
+
message="Resource Control Policy Principal must be '*'. "
|
|
207
|
+
f"Found: {statement.principal}. RCPs can only specify '*' in the "
|
|
208
|
+
"Principal element. Use Condition elements to restrict specific principals.",
|
|
209
|
+
statement_index=idx,
|
|
210
|
+
statement_sid=statement.sid,
|
|
211
|
+
line_number=statement.line_number,
|
|
212
|
+
suggestion='Change Principal to "*" and use Condition elements to restrict access.',
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# 3. Check for unsupported actions (actions not in supported services)
|
|
217
|
+
if statement.action:
|
|
218
|
+
actions = (
|
|
219
|
+
statement.action if isinstance(statement.action, list) else [statement.action]
|
|
220
|
+
)
|
|
221
|
+
unsupported_actions = []
|
|
222
|
+
|
|
223
|
+
for action in actions:
|
|
224
|
+
if isinstance(action, str):
|
|
225
|
+
# Check if action uses wildcard "*" alone (not allowed in customer RCPs)
|
|
226
|
+
if action == "*":
|
|
227
|
+
issues.append(
|
|
228
|
+
ValidationIssue(
|
|
229
|
+
severity="error",
|
|
230
|
+
issue_type="invalid_rcp_wildcard_action",
|
|
231
|
+
message="Resource Control Policy must not use '*' alone in Action element. "
|
|
232
|
+
"Customer-managed RCPs cannot use '*' as the action wildcard. "
|
|
233
|
+
"Use service-specific wildcards like 's3:*' instead.",
|
|
234
|
+
statement_index=idx,
|
|
235
|
+
statement_sid=statement.sid,
|
|
236
|
+
line_number=statement.line_number,
|
|
237
|
+
suggestion="Replace '*' with service-specific actions from supported "
|
|
238
|
+
f"services: {', '.join(sorted(rcp_supported_services))}",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Extract service from action (format: service:ActionName)
|
|
243
|
+
service = action.split(":")[0] if ":" in action else action
|
|
244
|
+
# Handle wildcards in service name
|
|
245
|
+
service_base = service.rstrip("*")
|
|
246
|
+
|
|
247
|
+
if service_base and service_base not in rcp_supported_services:
|
|
248
|
+
unsupported_actions.append(action)
|
|
249
|
+
|
|
250
|
+
if unsupported_actions:
|
|
251
|
+
issues.append(
|
|
252
|
+
ValidationIssue(
|
|
253
|
+
severity="error",
|
|
254
|
+
issue_type="unsupported_rcp_service",
|
|
255
|
+
message=f"Resource Control Policy contains actions from unsupported services: "
|
|
256
|
+
f"{', '.join(unsupported_actions)}. RCPs only support these services: "
|
|
257
|
+
f"{', '.join(sorted(rcp_supported_services))}",
|
|
258
|
+
statement_index=idx,
|
|
259
|
+
statement_sid=statement.sid,
|
|
260
|
+
line_number=statement.line_number,
|
|
261
|
+
suggestion=f"Use only actions from supported RCP services: "
|
|
262
|
+
f"{', '.join(sorted(rcp_supported_services))}",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 4. NotAction is not supported in RCPs
|
|
267
|
+
if statement.not_action:
|
|
268
|
+
issues.append(
|
|
269
|
+
ValidationIssue(
|
|
270
|
+
severity="error",
|
|
271
|
+
issue_type="invalid_rcp_not_action",
|
|
272
|
+
message="Resource Control Policy must not contain NotAction element. "
|
|
273
|
+
"RCPs do not support NotAction. Use Action element instead.",
|
|
274
|
+
statement_index=idx,
|
|
275
|
+
statement_sid=statement.sid,
|
|
276
|
+
line_number=statement.line_number,
|
|
277
|
+
suggestion="Replace NotAction with Action element listing the specific "
|
|
278
|
+
"actions to deny.",
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 5. Resource or NotResource is required
|
|
283
|
+
has_resource = statement.resource is not None
|
|
284
|
+
has_not_resource = statement.not_resource is not None
|
|
285
|
+
|
|
286
|
+
if not has_resource and not has_not_resource:
|
|
287
|
+
issues.append(
|
|
288
|
+
ValidationIssue(
|
|
289
|
+
severity="error",
|
|
290
|
+
issue_type="missing_rcp_resource",
|
|
291
|
+
message="Resource Control Policy statement must have Resource or NotResource element.",
|
|
292
|
+
statement_index=idx,
|
|
293
|
+
statement_sid=statement.sid,
|
|
294
|
+
line_number=statement.line_number,
|
|
295
|
+
suggestion='Add Resource: "*" or specify specific resource ARNs.',
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return issues
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Principal Validation Check.
|
|
2
|
+
|
|
3
|
+
Validates Principal elements in resource-based policies for security best practices.
|
|
4
|
+
This check enforces:
|
|
5
|
+
- Blocked principals (e.g., public access via "*")
|
|
6
|
+
- Allowed principals whitelist (optional)
|
|
7
|
+
- Required conditions for specific principals
|
|
8
|
+
- Service principal validation
|
|
9
|
+
|
|
10
|
+
Only runs for RESOURCE_POLICY type policies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
|
|
15
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
16
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
17
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrincipalValidationCheck(PolicyCheck):
|
|
21
|
+
"""Validates Principal elements in resource policies."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def check_id(self) -> str:
|
|
25
|
+
return "principal_validation_check"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def description(self) -> str:
|
|
29
|
+
return "Validates Principal elements in resource policies for security best practices"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def default_severity(self) -> str:
|
|
33
|
+
return "high"
|
|
34
|
+
|
|
35
|
+
async def execute(
|
|
36
|
+
self,
|
|
37
|
+
statement: Statement,
|
|
38
|
+
statement_idx: int,
|
|
39
|
+
fetcher: AWSServiceFetcher,
|
|
40
|
+
config: CheckConfig,
|
|
41
|
+
) -> list[ValidationIssue]:
|
|
42
|
+
"""Execute principal validation on a single statement.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
statement: The statement to validate
|
|
46
|
+
statement_idx: Index of the statement in the policy
|
|
47
|
+
fetcher: AWS service fetcher instance
|
|
48
|
+
config: Configuration for this check
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of validation issues
|
|
52
|
+
"""
|
|
53
|
+
issues = []
|
|
54
|
+
|
|
55
|
+
# Skip if no principal
|
|
56
|
+
if statement.principal is None and statement.not_principal is None:
|
|
57
|
+
return issues
|
|
58
|
+
|
|
59
|
+
# Get configuration
|
|
60
|
+
blocked_principals = config.config.get("blocked_principals", ["*"])
|
|
61
|
+
allowed_principals = config.config.get("allowed_principals", [])
|
|
62
|
+
require_conditions_for = config.config.get("require_conditions_for", {})
|
|
63
|
+
allowed_service_principals = config.config.get(
|
|
64
|
+
"allowed_service_principals",
|
|
65
|
+
[
|
|
66
|
+
"cloudfront.amazonaws.com",
|
|
67
|
+
"s3.amazonaws.com",
|
|
68
|
+
"sns.amazonaws.com",
|
|
69
|
+
"lambda.amazonaws.com",
|
|
70
|
+
"logs.amazonaws.com",
|
|
71
|
+
"events.amazonaws.com",
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Extract principals from statement
|
|
76
|
+
principals = self._extract_principals(statement)
|
|
77
|
+
|
|
78
|
+
for principal in principals:
|
|
79
|
+
# Check if principal is blocked
|
|
80
|
+
if self._is_blocked_principal(
|
|
81
|
+
principal, blocked_principals, allowed_service_principals
|
|
82
|
+
):
|
|
83
|
+
issues.append(
|
|
84
|
+
ValidationIssue(
|
|
85
|
+
severity=self.get_severity(config),
|
|
86
|
+
issue_type="blocked_principal",
|
|
87
|
+
message=f"Blocked principal detected: {principal}. "
|
|
88
|
+
f"This principal is explicitly blocked by your security policy.",
|
|
89
|
+
statement_index=statement_idx,
|
|
90
|
+
statement_sid=statement.sid,
|
|
91
|
+
line_number=statement.line_number,
|
|
92
|
+
suggestion=f"Remove the principal '{principal}' or add appropriate conditions to restrict access. "
|
|
93
|
+
"Consider using more specific principals instead of wildcards.",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Check if principal is in whitelist (if whitelist is configured)
|
|
99
|
+
if allowed_principals and not self._is_allowed_principal(
|
|
100
|
+
principal, allowed_principals, allowed_service_principals
|
|
101
|
+
):
|
|
102
|
+
issues.append(
|
|
103
|
+
ValidationIssue(
|
|
104
|
+
severity=self.get_severity(config),
|
|
105
|
+
issue_type="unauthorized_principal",
|
|
106
|
+
message=f"Principal not in allowed list: {principal}. "
|
|
107
|
+
f"Only principals in the allowed_principals whitelist are permitted.",
|
|
108
|
+
statement_index=statement_idx,
|
|
109
|
+
statement_sid=statement.sid,
|
|
110
|
+
line_number=statement.line_number,
|
|
111
|
+
suggestion=f"Add '{principal}' to the allowed_principals list in your config, "
|
|
112
|
+
"or use a principal that matches an allowed pattern.",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Check if principal requires conditions
|
|
118
|
+
required_conditions = self._get_required_conditions(principal, require_conditions_for)
|
|
119
|
+
if required_conditions:
|
|
120
|
+
missing_conditions = self._check_required_conditions(statement, required_conditions)
|
|
121
|
+
if missing_conditions:
|
|
122
|
+
issues.append(
|
|
123
|
+
ValidationIssue(
|
|
124
|
+
severity=self.get_severity(config),
|
|
125
|
+
issue_type="missing_principal_conditions",
|
|
126
|
+
message=f"Principal '{principal}' requires conditions: {', '.join(missing_conditions)}. "
|
|
127
|
+
f"This principal must have these condition keys to restrict access.",
|
|
128
|
+
statement_index=statement_idx,
|
|
129
|
+
statement_sid=statement.sid,
|
|
130
|
+
line_number=statement.line_number,
|
|
131
|
+
suggestion=f"Add conditions to restrict access:\n"
|
|
132
|
+
f"Example:\n"
|
|
133
|
+
f'"Condition": {{\n'
|
|
134
|
+
f' "StringEquals": {{\n'
|
|
135
|
+
f' "{missing_conditions[0]}": "value"\n'
|
|
136
|
+
f" }}\n"
|
|
137
|
+
f"}}",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return issues
|
|
142
|
+
|
|
143
|
+
def _extract_principals(self, statement: Statement) -> list[str]:
|
|
144
|
+
"""Extract all principals from a statement.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
statement: The statement to extract principals from
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
List of principal strings
|
|
151
|
+
"""
|
|
152
|
+
principals = []
|
|
153
|
+
|
|
154
|
+
# Handle Principal field
|
|
155
|
+
if statement.principal:
|
|
156
|
+
if isinstance(statement.principal, str):
|
|
157
|
+
# Simple string principal like "*"
|
|
158
|
+
principals.append(statement.principal)
|
|
159
|
+
elif isinstance(statement.principal, dict):
|
|
160
|
+
# Dict with AWS, Service, Federated, etc.
|
|
161
|
+
for key, value in statement.principal.items():
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
principals.append(value)
|
|
164
|
+
elif isinstance(value, list):
|
|
165
|
+
principals.extend(value)
|
|
166
|
+
|
|
167
|
+
# Handle NotPrincipal field (similar logic)
|
|
168
|
+
if statement.not_principal:
|
|
169
|
+
if isinstance(statement.not_principal, str):
|
|
170
|
+
principals.append(statement.not_principal)
|
|
171
|
+
elif isinstance(statement.not_principal, dict):
|
|
172
|
+
for key, value in statement.not_principal.items():
|
|
173
|
+
if isinstance(value, str):
|
|
174
|
+
principals.append(value)
|
|
175
|
+
elif isinstance(value, list):
|
|
176
|
+
principals.extend(value)
|
|
177
|
+
|
|
178
|
+
return principals
|
|
179
|
+
|
|
180
|
+
def _is_blocked_principal(
|
|
181
|
+
self, principal: str, blocked_list: list[str], service_whitelist: list[str]
|
|
182
|
+
) -> bool:
|
|
183
|
+
"""Check if a principal is blocked.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
principal: The principal to check
|
|
187
|
+
blocked_list: List of blocked principal patterns
|
|
188
|
+
service_whitelist: List of allowed service principals
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if the principal is blocked
|
|
192
|
+
"""
|
|
193
|
+
# Service principals are never blocked
|
|
194
|
+
if principal in service_whitelist:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
# Check against blocked list (supports wildcards)
|
|
198
|
+
for blocked_pattern in blocked_list:
|
|
199
|
+
# Special case: "*" in blocked list should only match literal "*" (public access)
|
|
200
|
+
# not use it as a wildcard pattern that matches everything
|
|
201
|
+
if blocked_pattern == "*":
|
|
202
|
+
if principal == "*":
|
|
203
|
+
return True
|
|
204
|
+
elif fnmatch.fnmatch(principal, blocked_pattern):
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def _is_allowed_principal(
|
|
210
|
+
self, principal: str, allowed_list: list[str], service_whitelist: list[str]
|
|
211
|
+
) -> bool:
|
|
212
|
+
"""Check if a principal is in the allowed list.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
principal: The principal to check
|
|
216
|
+
allowed_list: List of allowed principal patterns
|
|
217
|
+
service_whitelist: List of allowed service principals
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if the principal is allowed
|
|
221
|
+
"""
|
|
222
|
+
# Service principals are always allowed
|
|
223
|
+
if principal in service_whitelist:
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
# Check against allowed list (supports wildcards)
|
|
227
|
+
for allowed_pattern in allowed_list:
|
|
228
|
+
# Special case: "*" in allowed list should only match literal "*" (public access)
|
|
229
|
+
# not use it as a wildcard pattern that matches everything
|
|
230
|
+
if allowed_pattern == "*":
|
|
231
|
+
if principal == "*":
|
|
232
|
+
return True
|
|
233
|
+
elif fnmatch.fnmatch(principal, allowed_pattern):
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def _get_required_conditions(
|
|
239
|
+
self, principal: str, requirements: dict[str, list[str]]
|
|
240
|
+
) -> list[str]:
|
|
241
|
+
"""Get required condition keys for a principal.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
principal: The principal to check
|
|
245
|
+
requirements: Dict mapping principal patterns to required condition keys
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of required condition keys
|
|
249
|
+
"""
|
|
250
|
+
for pattern, condition_keys in requirements.items():
|
|
251
|
+
# Special case: "*" pattern should only match literal "*" (public access)
|
|
252
|
+
if pattern == "*":
|
|
253
|
+
if principal == "*":
|
|
254
|
+
return condition_keys
|
|
255
|
+
elif fnmatch.fnmatch(principal, pattern):
|
|
256
|
+
return condition_keys
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
def _check_required_conditions(
|
|
260
|
+
self, statement: Statement, required_keys: list[str]
|
|
261
|
+
) -> list[str]:
|
|
262
|
+
"""Check if statement has required condition keys.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
statement: The statement to check
|
|
266
|
+
required_keys: List of required condition keys
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of missing condition keys
|
|
270
|
+
"""
|
|
271
|
+
if not statement.condition:
|
|
272
|
+
return required_keys
|
|
273
|
+
|
|
274
|
+
# Flatten all condition keys from all condition operators
|
|
275
|
+
present_keys = set()
|
|
276
|
+
for operator_conditions in statement.condition.values():
|
|
277
|
+
if isinstance(operator_conditions, dict):
|
|
278
|
+
present_keys.update(operator_conditions.keys())
|
|
279
|
+
|
|
280
|
+
# Find missing keys
|
|
281
|
+
missing = [key for key in required_keys if key not in present_keys]
|
|
282
|
+
return missing
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Resource validation check - validates ARN formats."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
6
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
7
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResourceValidationCheck(PolicyCheck):
|
|
11
|
+
"""Validates ARN format for resources."""
|
|
12
|
+
|
|
13
|
+
# Maximum allowed length for ARN to prevent ReDoS attacks
|
|
14
|
+
MAX_ARN_LENGTH = 2048 # AWS max ARN length is ~2048 characters
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def check_id(self) -> str:
|
|
18
|
+
return "resource_validation"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def description(self) -> str:
|
|
22
|
+
return "Validates ARN format for resources"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def default_severity(self) -> str:
|
|
26
|
+
return "error"
|
|
27
|
+
|
|
28
|
+
async def execute(
|
|
29
|
+
self,
|
|
30
|
+
statement: Statement,
|
|
31
|
+
statement_idx: int,
|
|
32
|
+
fetcher: AWSServiceFetcher,
|
|
33
|
+
config: CheckConfig,
|
|
34
|
+
) -> list[ValidationIssue]:
|
|
35
|
+
"""Execute resource ARN validation on a statement."""
|
|
36
|
+
issues = []
|
|
37
|
+
|
|
38
|
+
# Get resources from statement
|
|
39
|
+
resources = statement.get_resources()
|
|
40
|
+
statement_sid = statement.sid
|
|
41
|
+
line_number = statement.line_number
|
|
42
|
+
|
|
43
|
+
# Get ARN pattern from config, or use default
|
|
44
|
+
# Pattern allows wildcards (*) in region and account fields
|
|
45
|
+
arn_pattern_str = config.config.get(
|
|
46
|
+
"arn_pattern",
|
|
47
|
+
r"^arn:(aws|aws-cn|aws-us-gov|aws-eusc|aws-iso|aws-iso-b|aws-iso-e|aws-iso-f):[a-z0-9\-]+:[a-z0-9\-*]*:[0-9*]*:.+$",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Compile pattern with timeout protection (available in Python 3.11+)
|
|
51
|
+
try:
|
|
52
|
+
arn_pattern = re.compile(arn_pattern_str)
|
|
53
|
+
except re.error:
|
|
54
|
+
# Fallback to using fetcher's pre-compiled pattern
|
|
55
|
+
arn_pattern = fetcher._patterns.arn_pattern
|
|
56
|
+
|
|
57
|
+
for resource in resources:
|
|
58
|
+
# Skip wildcard resources (handled by security checks)
|
|
59
|
+
if resource == "*":
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Validate ARN length to prevent ReDoS attacks
|
|
63
|
+
if len(resource) > self.MAX_ARN_LENGTH:
|
|
64
|
+
issues.append(
|
|
65
|
+
ValidationIssue(
|
|
66
|
+
severity=self.get_severity(config),
|
|
67
|
+
statement_sid=statement_sid,
|
|
68
|
+
statement_index=statement_idx,
|
|
69
|
+
issue_type="invalid_resource",
|
|
70
|
+
message=f"Resource ARN exceeds maximum length ({len(resource)} > {self.MAX_ARN_LENGTH}): {resource[:100]}...",
|
|
71
|
+
resource=resource[:100] + "...",
|
|
72
|
+
suggestion="ARN is too long and may be invalid",
|
|
73
|
+
line_number=line_number,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Validate ARN format
|
|
79
|
+
try:
|
|
80
|
+
if not arn_pattern.match(resource):
|
|
81
|
+
issues.append(
|
|
82
|
+
ValidationIssue(
|
|
83
|
+
severity=self.get_severity(config),
|
|
84
|
+
statement_sid=statement_sid,
|
|
85
|
+
statement_index=statement_idx,
|
|
86
|
+
issue_type="invalid_resource",
|
|
87
|
+
message=f"Invalid ARN format: {resource}",
|
|
88
|
+
resource=resource,
|
|
89
|
+
suggestion="ARN should follow format: arn:partition:service:region:account-id:resource",
|
|
90
|
+
line_number=line_number,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
# If regex matching fails (shouldn't happen with length check), treat as invalid
|
|
95
|
+
issues.append(
|
|
96
|
+
ValidationIssue(
|
|
97
|
+
severity=self.get_severity(config),
|
|
98
|
+
statement_sid=statement_sid,
|
|
99
|
+
statement_index=statement_idx,
|
|
100
|
+
issue_type="invalid_resource",
|
|
101
|
+
message=f"Could not validate ARN format: {resource}",
|
|
102
|
+
resource=resource,
|
|
103
|
+
suggestion="ARN validation failed - may contain unexpected characters",
|
|
104
|
+
line_number=line_number,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return issues
|