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.

Files changed (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. 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