iam-policy-validator 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of iam-policy-validator might be problematic. Click here for more details.
- iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
- iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
- iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +43 -0
- iam_validator/checks/action_condition_enforcement.py +884 -0
- iam_validator/checks/action_resource_matching.py +441 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +92 -0
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +71 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/policy_size.py +147 -0
- iam_validator/checks/policy_type_validation.py +305 -0
- iam_validator/checks/principal_validation.py +776 -0
- iam_validator/checks/resource_validation.py +138 -0
- iam_validator/checks/sensitive_action.py +254 -0
- iam_validator/checks/service_wildcard.py +107 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/checks/wildcard_action.py +67 -0
- iam_validator/checks/wildcard_resource.py +135 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +531 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +600 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +940 -0
- iam_validator/core/check_registry.py +607 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/config/config_loader.py +472 -0
- iam_validator/core/config/defaults.py +523 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/constants.py +74 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +440 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/models.py +327 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +424 -0
- iam_validator/core/report.py +872 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +815 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +206 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""AWS IAM Access Analyzer integration for policy validation.
|
|
2
|
+
|
|
3
|
+
This module provides integration with AWS IAM Access Analyzer ValidatePolicy API
|
|
4
|
+
to validate IAM policies for syntax errors, security warnings, and best practices.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import boto3
|
|
14
|
+
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
|
|
15
|
+
|
|
16
|
+
from iam_validator.core.policy_loader import PolicyLoader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PolicyType(str, Enum):
|
|
20
|
+
"""IAM Access Analyzer policy types."""
|
|
21
|
+
|
|
22
|
+
IDENTITY_POLICY = "IDENTITY_POLICY"
|
|
23
|
+
RESOURCE_POLICY = "RESOURCE_POLICY"
|
|
24
|
+
SERVICE_CONTROL_POLICY = "SERVICE_CONTROL_POLICY"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FindingType(str, Enum):
|
|
28
|
+
"""Access Analyzer finding types."""
|
|
29
|
+
|
|
30
|
+
ERROR = "ERROR"
|
|
31
|
+
SECURITY_WARNING = "SECURITY_WARNING"
|
|
32
|
+
SUGGESTION = "SUGGESTION"
|
|
33
|
+
WARNING = "WARNING"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CheckResultType(str, Enum):
|
|
37
|
+
"""Custom policy check result types."""
|
|
38
|
+
|
|
39
|
+
PASS = "PASS"
|
|
40
|
+
FAIL = "FAIL"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ResourceType(str, Enum):
|
|
44
|
+
"""Resource types for public access checks.
|
|
45
|
+
|
|
46
|
+
See: https://docs.aws.amazon.com/cli/latest/reference/accessanalyzer/check-no-public-access.html
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Storage
|
|
50
|
+
AWS_S3_BUCKET = "AWS::S3::Bucket"
|
|
51
|
+
AWS_S3_ACCESS_POINT = "AWS::S3::AccessPoint"
|
|
52
|
+
AWS_S3_MULTI_REGION_ACCESS_POINT = "AWS::S3::MultiRegionAccessPoint"
|
|
53
|
+
AWS_S3_EXPRESS_DIRECTORY_BUCKET = "AWS::S3Express::DirectoryBucket"
|
|
54
|
+
AWS_S3_EXPRESS_ACCESS_POINT = "AWS::S3Express::AccessPoint"
|
|
55
|
+
AWS_S3_GLACIER = "AWS::S3::Glacier"
|
|
56
|
+
AWS_S3_OUTPOSTS_BUCKET = "AWS::S3Outposts::Bucket"
|
|
57
|
+
AWS_S3_OUTPOSTS_ACCESS_POINT = "AWS::S3Outposts::AccessPoint"
|
|
58
|
+
AWS_S3_TABLES_TABLE_BUCKET = "AWS::S3Tables::TableBucket"
|
|
59
|
+
AWS_S3_TABLES_TABLE = "AWS::S3Tables::Table"
|
|
60
|
+
AWS_EFS_FILE_SYSTEM = "AWS::EFS::FileSystem"
|
|
61
|
+
|
|
62
|
+
# Database
|
|
63
|
+
AWS_DYNAMODB_TABLE = "AWS::DynamoDB::Table"
|
|
64
|
+
AWS_DYNAMODB_STREAM = "AWS::DynamoDB::Stream"
|
|
65
|
+
AWS_OPENSEARCH_DOMAIN = "AWS::OpenSearchService::Domain"
|
|
66
|
+
|
|
67
|
+
# Messaging & Streaming
|
|
68
|
+
AWS_KINESIS_STREAM = "AWS::Kinesis::Stream"
|
|
69
|
+
AWS_KINESIS_STREAM_CONSUMER = "AWS::Kinesis::StreamConsumer"
|
|
70
|
+
AWS_SNS_TOPIC = "AWS::SNS::Topic"
|
|
71
|
+
AWS_SQS_QUEUE = "AWS::SQS::Queue"
|
|
72
|
+
|
|
73
|
+
# Security & Secrets
|
|
74
|
+
AWS_KMS_KEY = "AWS::KMS::Key"
|
|
75
|
+
AWS_SECRETS_MANAGER_SECRET = "AWS::SecretsManager::Secret"
|
|
76
|
+
AWS_IAM_ASSUME_ROLE_POLICY = "AWS::IAM::AssumeRolePolicyDocument"
|
|
77
|
+
|
|
78
|
+
# Compute
|
|
79
|
+
AWS_LAMBDA_FUNCTION = "AWS::Lambda::Function"
|
|
80
|
+
|
|
81
|
+
# API & Integration
|
|
82
|
+
AWS_API_GATEWAY_REST_API = "AWS::ApiGateway::RestApi"
|
|
83
|
+
|
|
84
|
+
# DevOps & Management
|
|
85
|
+
AWS_CODE_ARTIFACT_DOMAIN = "AWS::CodeArtifact::Domain"
|
|
86
|
+
AWS_BACKUP_VAULT = "AWS::Backup::BackupVault"
|
|
87
|
+
AWS_CLOUDTRAIL_DASHBOARD = "AWS::CloudTrail::Dashboard"
|
|
88
|
+
AWS_CLOUDTRAIL_EVENT_DATA_STORE = "AWS::CloudTrail::EventDataStore"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ReasonSummary:
|
|
93
|
+
"""Represents a reason from custom policy checks."""
|
|
94
|
+
|
|
95
|
+
description: str
|
|
96
|
+
statement_index: int | None = None
|
|
97
|
+
statement_id: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class CustomCheckResult:
|
|
102
|
+
"""Result from a custom policy check (CheckAccessNotGranted, CheckNoNewAccess, etc.)."""
|
|
103
|
+
|
|
104
|
+
check_type: str # "AccessNotGranted", "NoNewAccess", "NoPublicAccess"
|
|
105
|
+
result: CheckResultType
|
|
106
|
+
message: str
|
|
107
|
+
reasons: list[ReasonSummary]
|
|
108
|
+
policy_file: str | None = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def passed(self) -> bool:
|
|
112
|
+
"""Check if the validation passed."""
|
|
113
|
+
return self.result == CheckResultType.PASS
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class AccessAnalyzerFinding:
|
|
118
|
+
"""Represents a finding from IAM Access Analyzer."""
|
|
119
|
+
|
|
120
|
+
finding_type: FindingType
|
|
121
|
+
issue_code: str
|
|
122
|
+
message: str
|
|
123
|
+
learn_more_link: str
|
|
124
|
+
locations: list[dict[str, Any]]
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def severity(self) -> str:
|
|
128
|
+
"""Map finding type to severity level."""
|
|
129
|
+
mapping = {
|
|
130
|
+
FindingType.ERROR: "error",
|
|
131
|
+
FindingType.SECURITY_WARNING: "warning",
|
|
132
|
+
FindingType.WARNING: "warning",
|
|
133
|
+
FindingType.SUGGESTION: "info",
|
|
134
|
+
}
|
|
135
|
+
return mapping.get(self.finding_type, "info")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class AccessAnalyzerResult:
|
|
140
|
+
"""Results from validating a policy with Access Analyzer."""
|
|
141
|
+
|
|
142
|
+
policy_file: str
|
|
143
|
+
is_valid: bool
|
|
144
|
+
findings: list[AccessAnalyzerFinding]
|
|
145
|
+
custom_checks: list[CustomCheckResult] | None = None
|
|
146
|
+
error: str | None = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def error_count(self) -> int:
|
|
150
|
+
"""Count of ERROR findings."""
|
|
151
|
+
return sum(1 for f in self.findings if f.finding_type == FindingType.ERROR)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def warning_count(self) -> int:
|
|
155
|
+
"""Count of WARNING and SECURITY_WARNING findings."""
|
|
156
|
+
return sum(
|
|
157
|
+
1
|
|
158
|
+
for f in self.findings
|
|
159
|
+
if f.finding_type in (FindingType.WARNING, FindingType.SECURITY_WARNING)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def suggestion_count(self) -> int:
|
|
164
|
+
"""Count of SUGGESTION findings."""
|
|
165
|
+
return sum(1 for f in self.findings if f.finding_type == FindingType.SUGGESTION)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def failed_custom_checks(self) -> int:
|
|
169
|
+
"""Count of failed custom checks."""
|
|
170
|
+
if not self.custom_checks:
|
|
171
|
+
return 0
|
|
172
|
+
return sum(1 for c in self.custom_checks if not c.passed)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class AccessAnalyzerReport:
|
|
177
|
+
"""Aggregated report from Access Analyzer validation."""
|
|
178
|
+
|
|
179
|
+
total_policies: int
|
|
180
|
+
valid_policies: int
|
|
181
|
+
invalid_policies: int
|
|
182
|
+
total_findings: int
|
|
183
|
+
results: list[AccessAnalyzerResult]
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def total_errors(self) -> int:
|
|
187
|
+
"""Total number of errors across all policies."""
|
|
188
|
+
return sum(r.error_count for r in self.results)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def total_warnings(self) -> int:
|
|
192
|
+
"""Total number of warnings across all policies."""
|
|
193
|
+
return sum(r.warning_count for r in self.results)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def total_suggestions(self) -> int:
|
|
197
|
+
"""Total number of suggestions across all policies."""
|
|
198
|
+
return sum(r.suggestion_count for r in self.results)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def policies_with_findings(self) -> int:
|
|
202
|
+
"""Number of policies that have at least one finding."""
|
|
203
|
+
return sum(1 for r in self.results if r.findings)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AccessAnalyzerValidator:
|
|
207
|
+
"""Validates IAM policies using AWS IAM Access Analyzer."""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
region: str = "us-east-1",
|
|
212
|
+
policy_type: PolicyType = PolicyType.IDENTITY_POLICY,
|
|
213
|
+
profile: str | None = None,
|
|
214
|
+
):
|
|
215
|
+
"""Initialize the Access Analyzer validator.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
region: AWS region to use for Access Analyzer API calls
|
|
219
|
+
policy_type: Type of policy to validate
|
|
220
|
+
profile: AWS profile name to use (optional)
|
|
221
|
+
"""
|
|
222
|
+
self.region = region
|
|
223
|
+
self.policy_type = policy_type
|
|
224
|
+
self.profile = profile
|
|
225
|
+
self.logger = logging.getLogger(__name__)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
session_kwargs: dict[str, Any] = {"region_name": region}
|
|
229
|
+
if profile:
|
|
230
|
+
session_kwargs["profile_name"] = profile
|
|
231
|
+
|
|
232
|
+
session = boto3.Session(**session_kwargs)
|
|
233
|
+
self.client = session.client("accessanalyzer")
|
|
234
|
+
self.logger.info(f"Initialized Access Analyzer client in region {region}")
|
|
235
|
+
except NoCredentialsError:
|
|
236
|
+
self.logger.error(
|
|
237
|
+
"AWS credentials not found. Please configure credentials using "
|
|
238
|
+
"AWS CLI, environment variables, or IAM role."
|
|
239
|
+
)
|
|
240
|
+
raise
|
|
241
|
+
except Exception as e:
|
|
242
|
+
self.logger.error(f"Failed to initialize Access Analyzer client: {e}")
|
|
243
|
+
raise
|
|
244
|
+
|
|
245
|
+
def validate_policy(self, policy_document: dict[str, Any]) -> list[AccessAnalyzerFinding]:
|
|
246
|
+
"""Validate a single policy document using Access Analyzer.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
policy_document: IAM policy document as a dictionary
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
List of findings from Access Analyzer
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
ClientError: If the API call fails
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
policy_json = json.dumps(policy_document)
|
|
259
|
+
|
|
260
|
+
response = self.client.validate_policy(
|
|
261
|
+
policyDocument=policy_json,
|
|
262
|
+
policyType=self.policy_type.value,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
findings = []
|
|
266
|
+
for finding_data in response.get("findings", []):
|
|
267
|
+
finding = AccessAnalyzerFinding(
|
|
268
|
+
finding_type=FindingType(finding_data["findingType"]),
|
|
269
|
+
issue_code=finding_data["issueCode"],
|
|
270
|
+
message=finding_data["findingDetails"],
|
|
271
|
+
learn_more_link=finding_data["learnMoreLink"],
|
|
272
|
+
locations=finding_data.get("locations", []),
|
|
273
|
+
)
|
|
274
|
+
findings.append(finding)
|
|
275
|
+
|
|
276
|
+
self.logger.debug(f"Validated policy, found {len(findings)} findings")
|
|
277
|
+
return findings
|
|
278
|
+
|
|
279
|
+
except ClientError as e:
|
|
280
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
281
|
+
error_msg = e.response.get("Error", {}).get("Message", str(e))
|
|
282
|
+
self.logger.error(f"Access Analyzer API error ({error_code}): {error_msg}")
|
|
283
|
+
raise
|
|
284
|
+
except BotoCoreError as e:
|
|
285
|
+
self.logger.error(f"AWS SDK error: {e}")
|
|
286
|
+
raise
|
|
287
|
+
except json.JSONDecodeError as e:
|
|
288
|
+
self.logger.error(f"Failed to serialize policy document: {e}")
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
def check_access_not_granted(
|
|
292
|
+
self,
|
|
293
|
+
policy_document: dict[str, Any],
|
|
294
|
+
actions: list[str],
|
|
295
|
+
resources: list[str] | None = None,
|
|
296
|
+
) -> CustomCheckResult:
|
|
297
|
+
"""Check that a policy does NOT grant specific access.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
policy_document: IAM policy document as a dictionary
|
|
301
|
+
actions: List of actions that should NOT be granted (e.g., ["s3:DeleteBucket"])
|
|
302
|
+
resources: Optional list of resources to check (e.g., ["arn:aws:s3:::my-bucket/*"])
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
CustomCheckResult with PASS/FAIL and reasons
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ClientError: If the API call fails
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
policy_json = json.dumps(policy_document)
|
|
312
|
+
|
|
313
|
+
# Build access specification
|
|
314
|
+
access_spec: dict[str, Any] = {"actions": actions}
|
|
315
|
+
if resources:
|
|
316
|
+
access_spec["resources"] = resources
|
|
317
|
+
|
|
318
|
+
response = self.client.check_access_not_granted(
|
|
319
|
+
policyDocument=policy_json,
|
|
320
|
+
access=[access_spec],
|
|
321
|
+
policyType=self.policy_type.value,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Parse response
|
|
325
|
+
result = CheckResultType(response["result"])
|
|
326
|
+
message = response.get("message", "")
|
|
327
|
+
|
|
328
|
+
reasons = []
|
|
329
|
+
for reason_data in response.get("reasons", []):
|
|
330
|
+
reason = ReasonSummary(
|
|
331
|
+
description=reason_data.get("description", ""),
|
|
332
|
+
statement_index=reason_data.get("statementIndex"),
|
|
333
|
+
statement_id=reason_data.get("statementId"),
|
|
334
|
+
)
|
|
335
|
+
reasons.append(reason)
|
|
336
|
+
|
|
337
|
+
check_result = CustomCheckResult(
|
|
338
|
+
check_type="AccessNotGranted",
|
|
339
|
+
result=result,
|
|
340
|
+
message=message,
|
|
341
|
+
reasons=reasons,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
self.logger.debug(f"CheckAccessNotGranted: {result.value} - {len(reasons)} reasons")
|
|
345
|
+
return check_result
|
|
346
|
+
|
|
347
|
+
except ClientError as e:
|
|
348
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
349
|
+
error_msg = e.response.get("Error", {}).get("Message", str(e))
|
|
350
|
+
self.logger.error(f"CheckAccessNotGranted API error ({error_code}): {error_msg}")
|
|
351
|
+
raise
|
|
352
|
+
except BotoCoreError as e:
|
|
353
|
+
self.logger.error(f"AWS SDK error: {e}")
|
|
354
|
+
raise
|
|
355
|
+
|
|
356
|
+
def check_no_new_access(
|
|
357
|
+
self,
|
|
358
|
+
new_policy_document: dict[str, Any],
|
|
359
|
+
existing_policy_document: dict[str, Any],
|
|
360
|
+
) -> CustomCheckResult:
|
|
361
|
+
"""Check that a new policy doesn't grant new access compared to existing policy.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
new_policy_document: New/updated IAM policy document
|
|
365
|
+
existing_policy_document: Existing/reference IAM policy document
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
CustomCheckResult with PASS/FAIL and reasons
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
ClientError: If the API call fails
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
new_policy_json = json.dumps(new_policy_document)
|
|
375
|
+
existing_policy_json = json.dumps(existing_policy_document)
|
|
376
|
+
|
|
377
|
+
response = self.client.check_no_new_access(
|
|
378
|
+
newPolicyDocument=new_policy_json,
|
|
379
|
+
existingPolicyDocument=existing_policy_json,
|
|
380
|
+
policyType=self.policy_type.value,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Parse response
|
|
384
|
+
result = CheckResultType(response["result"])
|
|
385
|
+
message = response.get("message", "")
|
|
386
|
+
|
|
387
|
+
reasons = []
|
|
388
|
+
for reason_data in response.get("reasons", []):
|
|
389
|
+
reason = ReasonSummary(
|
|
390
|
+
description=reason_data.get("description", ""),
|
|
391
|
+
statement_index=reason_data.get("statementIndex"),
|
|
392
|
+
statement_id=reason_data.get("statementId"),
|
|
393
|
+
)
|
|
394
|
+
reasons.append(reason)
|
|
395
|
+
|
|
396
|
+
check_result = CustomCheckResult(
|
|
397
|
+
check_type="NoNewAccess",
|
|
398
|
+
result=result,
|
|
399
|
+
message=message,
|
|
400
|
+
reasons=reasons,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
self.logger.debug(f"CheckNoNewAccess: {result.value} - {len(reasons)} reasons")
|
|
404
|
+
return check_result
|
|
405
|
+
|
|
406
|
+
except ClientError as e:
|
|
407
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
408
|
+
error_msg = e.response.get("Error", {}).get("Message", str(e))
|
|
409
|
+
self.logger.error(f"CheckNoNewAccess API error ({error_code}): {error_msg}")
|
|
410
|
+
raise
|
|
411
|
+
except BotoCoreError as e:
|
|
412
|
+
self.logger.error(f"AWS SDK error: {e}")
|
|
413
|
+
raise
|
|
414
|
+
|
|
415
|
+
def check_no_public_access(
|
|
416
|
+
self,
|
|
417
|
+
policy_document: dict[str, Any],
|
|
418
|
+
resource_type: ResourceType,
|
|
419
|
+
) -> CustomCheckResult:
|
|
420
|
+
"""Check that a resource policy doesn't allow public access.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
policy_document: Resource policy document (e.g., S3 bucket policy)
|
|
424
|
+
resource_type: Type of AWS resource
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
CustomCheckResult with PASS/FAIL and reasons
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
ClientError: If the API call fails
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
policy_json = json.dumps(policy_document)
|
|
434
|
+
|
|
435
|
+
response = self.client.check_no_public_access(
|
|
436
|
+
policyDocument=policy_json,
|
|
437
|
+
resourceType=resource_type.value,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Parse response
|
|
441
|
+
result = CheckResultType(response["result"])
|
|
442
|
+
message = response.get("message", "")
|
|
443
|
+
|
|
444
|
+
reasons = []
|
|
445
|
+
for reason_data in response.get("reasons", []):
|
|
446
|
+
reason = ReasonSummary(
|
|
447
|
+
description=reason_data.get("description", ""),
|
|
448
|
+
statement_index=reason_data.get("statementIndex"),
|
|
449
|
+
statement_id=reason_data.get("statementId"),
|
|
450
|
+
)
|
|
451
|
+
reasons.append(reason)
|
|
452
|
+
|
|
453
|
+
check_result = CustomCheckResult(
|
|
454
|
+
check_type=f"NoPublicAccess ({resource_type.value})",
|
|
455
|
+
result=result,
|
|
456
|
+
message=message,
|
|
457
|
+
reasons=reasons,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
self.logger.debug(f"CheckNoPublicAccess: {result.value} - {len(reasons)} reasons")
|
|
461
|
+
return check_result
|
|
462
|
+
|
|
463
|
+
except ClientError as e:
|
|
464
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
465
|
+
error_msg = e.response.get("Error", {}).get("Message", str(e))
|
|
466
|
+
self.logger.error(f"CheckNoPublicAccess API error ({error_code}): {error_msg}")
|
|
467
|
+
raise
|
|
468
|
+
except BotoCoreError as e:
|
|
469
|
+
self.logger.error(f"AWS SDK error: {e}")
|
|
470
|
+
raise
|
|
471
|
+
|
|
472
|
+
def validate_policies(
|
|
473
|
+
self,
|
|
474
|
+
policies: list[tuple[str, dict[str, Any]]],
|
|
475
|
+
custom_checks: dict[str, Any] | None = None,
|
|
476
|
+
) -> list[AccessAnalyzerResult]:
|
|
477
|
+
"""Validate multiple policies.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
policies: List of tuples containing (file_path, policy_document)
|
|
481
|
+
custom_checks: Optional dictionary with custom check configurations:
|
|
482
|
+
- 'access_not_granted': {'actions': [...], 'resources': [...]}
|
|
483
|
+
- 'no_new_access': {'existing_policies': {policy_file: policy_doc}}
|
|
484
|
+
- 'no_public_access': {'resource_types': [ResourceType, ...]}
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
List of validation results
|
|
488
|
+
"""
|
|
489
|
+
results = []
|
|
490
|
+
|
|
491
|
+
for policy_file, policy_doc in policies:
|
|
492
|
+
self.logger.info(f"Validating policy: {policy_file}")
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
findings = self.validate_policy(policy_doc)
|
|
496
|
+
has_errors = any(f.finding_type == FindingType.ERROR for f in findings)
|
|
497
|
+
|
|
498
|
+
# Run custom checks if specified
|
|
499
|
+
custom_check_results = []
|
|
500
|
+
if custom_checks:
|
|
501
|
+
# Check access not granted
|
|
502
|
+
if "access_not_granted" in custom_checks:
|
|
503
|
+
config = custom_checks["access_not_granted"]
|
|
504
|
+
|
|
505
|
+
# Validate configuration structure
|
|
506
|
+
if not isinstance(config, dict):
|
|
507
|
+
self.logger.warning(
|
|
508
|
+
f"Invalid access_not_granted configuration for {policy_file}: "
|
|
509
|
+
"expected dict, skipping check"
|
|
510
|
+
)
|
|
511
|
+
elif "actions" not in config:
|
|
512
|
+
self.logger.warning(
|
|
513
|
+
f"access_not_granted configuration missing 'actions' "
|
|
514
|
+
f"for {policy_file}, skipping check"
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
check_result = self.check_access_not_granted(
|
|
518
|
+
policy_doc,
|
|
519
|
+
actions=config["actions"],
|
|
520
|
+
resources=config.get("resources"),
|
|
521
|
+
)
|
|
522
|
+
check_result.policy_file = policy_file
|
|
523
|
+
custom_check_results.append(check_result)
|
|
524
|
+
|
|
525
|
+
# Check no new access
|
|
526
|
+
if "no_new_access" in custom_checks:
|
|
527
|
+
no_new_access_config = custom_checks["no_new_access"]
|
|
528
|
+
|
|
529
|
+
# Validate configuration structure
|
|
530
|
+
if not isinstance(no_new_access_config, dict):
|
|
531
|
+
self.logger.warning(
|
|
532
|
+
f"Invalid no_new_access configuration for {policy_file}: "
|
|
533
|
+
"expected dict, skipping check"
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
existing_policies = no_new_access_config.get("existing_policies", {})
|
|
537
|
+
if policy_file in existing_policies:
|
|
538
|
+
check_result = self.check_no_new_access(
|
|
539
|
+
policy_doc, existing_policies[policy_file]
|
|
540
|
+
)
|
|
541
|
+
check_result.policy_file = policy_file
|
|
542
|
+
custom_check_results.append(check_result)
|
|
543
|
+
|
|
544
|
+
# Check no public access (supports multiple resource types)
|
|
545
|
+
if "no_public_access" in custom_checks:
|
|
546
|
+
no_public_config = custom_checks["no_public_access"]
|
|
547
|
+
|
|
548
|
+
# Validate configuration structure
|
|
549
|
+
if not isinstance(no_public_config, dict):
|
|
550
|
+
self.logger.warning(
|
|
551
|
+
f"Invalid no_public_access configuration for {policy_file}: "
|
|
552
|
+
"expected dict, skipping check"
|
|
553
|
+
)
|
|
554
|
+
elif "resource_types" not in no_public_config:
|
|
555
|
+
self.logger.warning(
|
|
556
|
+
f"no_public_access configuration missing 'resource_types' "
|
|
557
|
+
f"for {policy_file}, skipping check"
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
resource_types = no_public_config["resource_types"]
|
|
561
|
+
# Support both single ResourceType and list
|
|
562
|
+
if not isinstance(resource_types, list):
|
|
563
|
+
resource_types = [resource_types]
|
|
564
|
+
|
|
565
|
+
for resource_type in resource_types:
|
|
566
|
+
check_result = self.check_no_public_access(
|
|
567
|
+
policy_doc, resource_type
|
|
568
|
+
)
|
|
569
|
+
check_result.policy_file = policy_file
|
|
570
|
+
custom_check_results.append(check_result)
|
|
571
|
+
|
|
572
|
+
result = AccessAnalyzerResult(
|
|
573
|
+
policy_file=policy_file,
|
|
574
|
+
is_valid=not has_errors,
|
|
575
|
+
findings=findings,
|
|
576
|
+
custom_checks=(custom_check_results if custom_check_results else None),
|
|
577
|
+
)
|
|
578
|
+
results.append(result)
|
|
579
|
+
|
|
580
|
+
except Exception as e:
|
|
581
|
+
self.logger.error(f"Failed to validate {policy_file}: {e}")
|
|
582
|
+
result = AccessAnalyzerResult(
|
|
583
|
+
policy_file=policy_file,
|
|
584
|
+
is_valid=False,
|
|
585
|
+
findings=[],
|
|
586
|
+
error=str(e),
|
|
587
|
+
)
|
|
588
|
+
results.append(result)
|
|
589
|
+
|
|
590
|
+
return results
|
|
591
|
+
|
|
592
|
+
def generate_report(self, results: list[AccessAnalyzerResult]) -> AccessAnalyzerReport:
|
|
593
|
+
"""Generate a summary report from validation results.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
results: List of validation results
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Aggregated report
|
|
600
|
+
"""
|
|
601
|
+
total_policies = len(results)
|
|
602
|
+
valid_policies = sum(1 for r in results if r.is_valid and not r.error)
|
|
603
|
+
invalid_policies = total_policies - valid_policies
|
|
604
|
+
total_findings = sum(len(r.findings) for r in results)
|
|
605
|
+
|
|
606
|
+
return AccessAnalyzerReport(
|
|
607
|
+
total_policies=total_policies,
|
|
608
|
+
valid_policies=valid_policies,
|
|
609
|
+
invalid_policies=invalid_policies,
|
|
610
|
+
total_findings=total_findings,
|
|
611
|
+
results=results,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def validate_policies_with_analyzer(
|
|
616
|
+
path: str | list[str],
|
|
617
|
+
region: str = "us-east-1",
|
|
618
|
+
policy_type: PolicyType = PolicyType.IDENTITY_POLICY,
|
|
619
|
+
profile: str | None = None,
|
|
620
|
+
recursive: bool = True,
|
|
621
|
+
custom_checks: dict[str, Any] | None = None,
|
|
622
|
+
) -> AccessAnalyzerReport:
|
|
623
|
+
"""Validate IAM policies from file(s) or director(ies) using Access Analyzer.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
path: Path to policy file/directory, or list of paths
|
|
627
|
+
region: AWS region for Access Analyzer
|
|
628
|
+
policy_type: Type of policy to validate
|
|
629
|
+
profile: AWS profile name (optional)
|
|
630
|
+
recursive: Whether to search directories recursively
|
|
631
|
+
custom_checks: Optional custom check configurations
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
Validation report
|
|
635
|
+
|
|
636
|
+
Raises:
|
|
637
|
+
ValueError: If no policies found
|
|
638
|
+
ClientError: If AWS API calls fail
|
|
639
|
+
"""
|
|
640
|
+
# Load policies
|
|
641
|
+
loader = PolicyLoader()
|
|
642
|
+
if isinstance(path, list):
|
|
643
|
+
loaded_policies = loader.load_from_paths(path, recursive=recursive)
|
|
644
|
+
path_description = ", ".join(path)
|
|
645
|
+
else:
|
|
646
|
+
loaded_policies = loader.load_from_path(path, recursive=recursive)
|
|
647
|
+
path_description = path
|
|
648
|
+
|
|
649
|
+
if not loaded_policies:
|
|
650
|
+
raise ValueError(f"No valid IAM policies found in {path_description}")
|
|
651
|
+
|
|
652
|
+
logging.info(f"Loaded {len(loaded_policies)} policies for Access Analyzer validation")
|
|
653
|
+
|
|
654
|
+
# Convert IAMPolicy models to dicts for Access Analyzer
|
|
655
|
+
# Use by_alias=True to export with capitalized field names (Version, Statement, etc.)
|
|
656
|
+
policy_dicts: list[tuple[str, dict[str, Any]]] = [
|
|
657
|
+
(file_path, policy.model_dump(by_alias=True, exclude_none=True))
|
|
658
|
+
for file_path, policy in loaded_policies
|
|
659
|
+
]
|
|
660
|
+
|
|
661
|
+
# Validate with Access Analyzer
|
|
662
|
+
validator = AccessAnalyzerValidator(
|
|
663
|
+
region=region,
|
|
664
|
+
policy_type=policy_type,
|
|
665
|
+
profile=profile,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
results = validator.validate_policies(policy_dicts, custom_checks=custom_checks)
|
|
669
|
+
report = validator.generate_report(results)
|
|
670
|
+
|
|
671
|
+
return report
|