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.

Files changed (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.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 +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,666 @@
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
+
201
+ class AccessAnalyzerValidator:
202
+ """Validates IAM policies using AWS IAM Access Analyzer."""
203
+
204
+ def __init__(
205
+ self,
206
+ region: str = "us-east-1",
207
+ policy_type: PolicyType = PolicyType.IDENTITY_POLICY,
208
+ profile: str | None = None,
209
+ ):
210
+ """Initialize the Access Analyzer validator.
211
+
212
+ Args:
213
+ region: AWS region to use for Access Analyzer API calls
214
+ policy_type: Type of policy to validate
215
+ profile: AWS profile name to use (optional)
216
+ """
217
+ self.region = region
218
+ self.policy_type = policy_type
219
+ self.profile = profile
220
+ self.logger = logging.getLogger(__name__)
221
+
222
+ try:
223
+ session_kwargs: dict[str, Any] = {"region_name": region}
224
+ if profile:
225
+ session_kwargs["profile_name"] = profile
226
+
227
+ session = boto3.Session(**session_kwargs)
228
+ self.client = session.client("accessanalyzer")
229
+ self.logger.info(f"Initialized Access Analyzer client in region {region}")
230
+ except NoCredentialsError:
231
+ self.logger.error(
232
+ "AWS credentials not found. Please configure credentials using "
233
+ "AWS CLI, environment variables, or IAM role."
234
+ )
235
+ raise
236
+ except Exception as e:
237
+ self.logger.error(f"Failed to initialize Access Analyzer client: {e}")
238
+ raise
239
+
240
+ def validate_policy(self, policy_document: dict[str, Any]) -> list[AccessAnalyzerFinding]:
241
+ """Validate a single policy document using Access Analyzer.
242
+
243
+ Args:
244
+ policy_document: IAM policy document as a dictionary
245
+
246
+ Returns:
247
+ List of findings from Access Analyzer
248
+
249
+ Raises:
250
+ ClientError: If the API call fails
251
+ """
252
+ try:
253
+ policy_json = json.dumps(policy_document)
254
+
255
+ response = self.client.validate_policy(
256
+ policyDocument=policy_json,
257
+ policyType=self.policy_type.value,
258
+ )
259
+
260
+ findings = []
261
+ for finding_data in response.get("findings", []):
262
+ finding = AccessAnalyzerFinding(
263
+ finding_type=FindingType(finding_data["findingType"]),
264
+ issue_code=finding_data["issueCode"],
265
+ message=finding_data["findingDetails"],
266
+ learn_more_link=finding_data["learnMoreLink"],
267
+ locations=finding_data.get("locations", []),
268
+ )
269
+ findings.append(finding)
270
+
271
+ self.logger.debug(f"Validated policy, found {len(findings)} findings")
272
+ return findings
273
+
274
+ except ClientError as e:
275
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
276
+ error_msg = e.response.get("Error", {}).get("Message", str(e))
277
+ self.logger.error(f"Access Analyzer API error ({error_code}): {error_msg}")
278
+ raise
279
+ except BotoCoreError as e:
280
+ self.logger.error(f"AWS SDK error: {e}")
281
+ raise
282
+ except json.JSONDecodeError as e:
283
+ self.logger.error(f"Failed to serialize policy document: {e}")
284
+ raise
285
+
286
+ def check_access_not_granted(
287
+ self,
288
+ policy_document: dict[str, Any],
289
+ actions: list[str],
290
+ resources: list[str] | None = None,
291
+ ) -> CustomCheckResult:
292
+ """Check that a policy does NOT grant specific access.
293
+
294
+ Args:
295
+ policy_document: IAM policy document as a dictionary
296
+ actions: List of actions that should NOT be granted (e.g., ["s3:DeleteBucket"])
297
+ resources: Optional list of resources to check (e.g., ["arn:aws:s3:::my-bucket/*"])
298
+
299
+ Returns:
300
+ CustomCheckResult with PASS/FAIL and reasons
301
+
302
+ Raises:
303
+ ClientError: If the API call fails
304
+ """
305
+ try:
306
+ policy_json = json.dumps(policy_document)
307
+
308
+ # Build access specification
309
+ access_spec: dict[str, Any] = {"actions": actions}
310
+ if resources:
311
+ access_spec["resources"] = resources
312
+
313
+ response = self.client.check_access_not_granted(
314
+ policyDocument=policy_json,
315
+ access=[access_spec],
316
+ policyType=self.policy_type.value,
317
+ )
318
+
319
+ # Parse response
320
+ result = CheckResultType(response["result"])
321
+ message = response.get("message", "")
322
+
323
+ reasons = []
324
+ for reason_data in response.get("reasons", []):
325
+ reason = ReasonSummary(
326
+ description=reason_data.get("description", ""),
327
+ statement_index=reason_data.get("statementIndex"),
328
+ statement_id=reason_data.get("statementId"),
329
+ )
330
+ reasons.append(reason)
331
+
332
+ check_result = CustomCheckResult(
333
+ check_type="AccessNotGranted",
334
+ result=result,
335
+ message=message,
336
+ reasons=reasons,
337
+ )
338
+
339
+ self.logger.debug(f"CheckAccessNotGranted: {result.value} - {len(reasons)} reasons")
340
+ return check_result
341
+
342
+ except ClientError as e:
343
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
344
+ error_msg = e.response.get("Error", {}).get("Message", str(e))
345
+ self.logger.error(f"CheckAccessNotGranted API error ({error_code}): {error_msg}")
346
+ raise
347
+ except BotoCoreError as e:
348
+ self.logger.error(f"AWS SDK error: {e}")
349
+ raise
350
+
351
+ def check_no_new_access(
352
+ self,
353
+ new_policy_document: dict[str, Any],
354
+ existing_policy_document: dict[str, Any],
355
+ ) -> CustomCheckResult:
356
+ """Check that a new policy doesn't grant new access compared to existing policy.
357
+
358
+ Args:
359
+ new_policy_document: New/updated IAM policy document
360
+ existing_policy_document: Existing/reference IAM policy document
361
+
362
+ Returns:
363
+ CustomCheckResult with PASS/FAIL and reasons
364
+
365
+ Raises:
366
+ ClientError: If the API call fails
367
+ """
368
+ try:
369
+ new_policy_json = json.dumps(new_policy_document)
370
+ existing_policy_json = json.dumps(existing_policy_document)
371
+
372
+ response = self.client.check_no_new_access(
373
+ newPolicyDocument=new_policy_json,
374
+ existingPolicyDocument=existing_policy_json,
375
+ policyType=self.policy_type.value,
376
+ )
377
+
378
+ # Parse response
379
+ result = CheckResultType(response["result"])
380
+ message = response.get("message", "")
381
+
382
+ reasons = []
383
+ for reason_data in response.get("reasons", []):
384
+ reason = ReasonSummary(
385
+ description=reason_data.get("description", ""),
386
+ statement_index=reason_data.get("statementIndex"),
387
+ statement_id=reason_data.get("statementId"),
388
+ )
389
+ reasons.append(reason)
390
+
391
+ check_result = CustomCheckResult(
392
+ check_type="NoNewAccess",
393
+ result=result,
394
+ message=message,
395
+ reasons=reasons,
396
+ )
397
+
398
+ self.logger.debug(f"CheckNoNewAccess: {result.value} - {len(reasons)} reasons")
399
+ return check_result
400
+
401
+ except ClientError as e:
402
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
403
+ error_msg = e.response.get("Error", {}).get("Message", str(e))
404
+ self.logger.error(f"CheckNoNewAccess API error ({error_code}): {error_msg}")
405
+ raise
406
+ except BotoCoreError as e:
407
+ self.logger.error(f"AWS SDK error: {e}")
408
+ raise
409
+
410
+ def check_no_public_access(
411
+ self,
412
+ policy_document: dict[str, Any],
413
+ resource_type: ResourceType,
414
+ ) -> CustomCheckResult:
415
+ """Check that a resource policy doesn't allow public access.
416
+
417
+ Args:
418
+ policy_document: Resource policy document (e.g., S3 bucket policy)
419
+ resource_type: Type of AWS resource
420
+
421
+ Returns:
422
+ CustomCheckResult with PASS/FAIL and reasons
423
+
424
+ Raises:
425
+ ClientError: If the API call fails
426
+ """
427
+ try:
428
+ policy_json = json.dumps(policy_document)
429
+
430
+ response = self.client.check_no_public_access(
431
+ policyDocument=policy_json,
432
+ resourceType=resource_type.value,
433
+ )
434
+
435
+ # Parse response
436
+ result = CheckResultType(response["result"])
437
+ message = response.get("message", "")
438
+
439
+ reasons = []
440
+ for reason_data in response.get("reasons", []):
441
+ reason = ReasonSummary(
442
+ description=reason_data.get("description", ""),
443
+ statement_index=reason_data.get("statementIndex"),
444
+ statement_id=reason_data.get("statementId"),
445
+ )
446
+ reasons.append(reason)
447
+
448
+ check_result = CustomCheckResult(
449
+ check_type=f"NoPublicAccess ({resource_type.value})",
450
+ result=result,
451
+ message=message,
452
+ reasons=reasons,
453
+ )
454
+
455
+ self.logger.debug(f"CheckNoPublicAccess: {result.value} - {len(reasons)} reasons")
456
+ return check_result
457
+
458
+ except ClientError as e:
459
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
460
+ error_msg = e.response.get("Error", {}).get("Message", str(e))
461
+ self.logger.error(f"CheckNoPublicAccess API error ({error_code}): {error_msg}")
462
+ raise
463
+ except BotoCoreError as e:
464
+ self.logger.error(f"AWS SDK error: {e}")
465
+ raise
466
+
467
+ def validate_policies(
468
+ self,
469
+ policies: list[tuple[str, dict[str, Any]]],
470
+ custom_checks: dict[str, Any] | None = None,
471
+ ) -> list[AccessAnalyzerResult]:
472
+ """Validate multiple policies.
473
+
474
+ Args:
475
+ policies: List of tuples containing (file_path, policy_document)
476
+ custom_checks: Optional dictionary with custom check configurations:
477
+ - 'access_not_granted': {'actions': [...], 'resources': [...]}
478
+ - 'no_new_access': {'existing_policies': {policy_file: policy_doc}}
479
+ - 'no_public_access': {'resource_types': [ResourceType, ...]}
480
+
481
+ Returns:
482
+ List of validation results
483
+ """
484
+ results = []
485
+
486
+ for policy_file, policy_doc in policies:
487
+ self.logger.info(f"Validating policy: {policy_file}")
488
+
489
+ try:
490
+ findings = self.validate_policy(policy_doc)
491
+ has_errors = any(f.finding_type == FindingType.ERROR for f in findings)
492
+
493
+ # Run custom checks if specified
494
+ custom_check_results = []
495
+ if custom_checks:
496
+ # Check access not granted
497
+ if "access_not_granted" in custom_checks:
498
+ config = custom_checks["access_not_granted"]
499
+
500
+ # Validate configuration structure
501
+ if not isinstance(config, dict):
502
+ self.logger.warning(
503
+ f"Invalid access_not_granted configuration for {policy_file}: "
504
+ "expected dict, skipping check"
505
+ )
506
+ elif "actions" not in config:
507
+ self.logger.warning(
508
+ f"access_not_granted configuration missing 'actions' "
509
+ f"for {policy_file}, skipping check"
510
+ )
511
+ else:
512
+ check_result = self.check_access_not_granted(
513
+ policy_doc,
514
+ actions=config["actions"],
515
+ resources=config.get("resources"),
516
+ )
517
+ check_result.policy_file = policy_file
518
+ custom_check_results.append(check_result)
519
+
520
+ # Check no new access
521
+ if "no_new_access" in custom_checks:
522
+ no_new_access_config = custom_checks["no_new_access"]
523
+
524
+ # Validate configuration structure
525
+ if not isinstance(no_new_access_config, dict):
526
+ self.logger.warning(
527
+ f"Invalid no_new_access configuration for {policy_file}: "
528
+ "expected dict, skipping check"
529
+ )
530
+ else:
531
+ existing_policies = no_new_access_config.get("existing_policies", {})
532
+ if policy_file in existing_policies:
533
+ check_result = self.check_no_new_access(
534
+ policy_doc, existing_policies[policy_file]
535
+ )
536
+ check_result.policy_file = policy_file
537
+ custom_check_results.append(check_result)
538
+
539
+ # Check no public access (supports multiple resource types)
540
+ if "no_public_access" in custom_checks:
541
+ no_public_config = custom_checks["no_public_access"]
542
+
543
+ # Validate configuration structure
544
+ if not isinstance(no_public_config, dict):
545
+ self.logger.warning(
546
+ f"Invalid no_public_access configuration for {policy_file}: "
547
+ "expected dict, skipping check"
548
+ )
549
+ elif "resource_types" not in no_public_config:
550
+ self.logger.warning(
551
+ f"no_public_access configuration missing 'resource_types' "
552
+ f"for {policy_file}, skipping check"
553
+ )
554
+ else:
555
+ resource_types = no_public_config["resource_types"]
556
+ # Support both single ResourceType and list
557
+ if not isinstance(resource_types, list):
558
+ resource_types = [resource_types]
559
+
560
+ for resource_type in resource_types:
561
+ check_result = self.check_no_public_access(
562
+ policy_doc, resource_type
563
+ )
564
+ check_result.policy_file = policy_file
565
+ custom_check_results.append(check_result)
566
+
567
+ result = AccessAnalyzerResult(
568
+ policy_file=policy_file,
569
+ is_valid=not has_errors,
570
+ findings=findings,
571
+ custom_checks=(custom_check_results if custom_check_results else None),
572
+ )
573
+ results.append(result)
574
+
575
+ except Exception as e:
576
+ self.logger.error(f"Failed to validate {policy_file}: {e}")
577
+ result = AccessAnalyzerResult(
578
+ policy_file=policy_file,
579
+ is_valid=False,
580
+ findings=[],
581
+ error=str(e),
582
+ )
583
+ results.append(result)
584
+
585
+ return results
586
+
587
+ def generate_report(self, results: list[AccessAnalyzerResult]) -> AccessAnalyzerReport:
588
+ """Generate a summary report from validation results.
589
+
590
+ Args:
591
+ results: List of validation results
592
+
593
+ Returns:
594
+ Aggregated report
595
+ """
596
+ total_policies = len(results)
597
+ valid_policies = sum(1 for r in results if r.is_valid and not r.error)
598
+ invalid_policies = total_policies - valid_policies
599
+ total_findings = sum(len(r.findings) for r in results)
600
+
601
+ return AccessAnalyzerReport(
602
+ total_policies=total_policies,
603
+ valid_policies=valid_policies,
604
+ invalid_policies=invalid_policies,
605
+ total_findings=total_findings,
606
+ results=results,
607
+ )
608
+
609
+
610
+ def validate_policies_with_analyzer(
611
+ path: str | list[str],
612
+ region: str = "us-east-1",
613
+ policy_type: PolicyType = PolicyType.IDENTITY_POLICY,
614
+ profile: str | None = None,
615
+ recursive: bool = True,
616
+ custom_checks: dict[str, Any] | None = None,
617
+ ) -> AccessAnalyzerReport:
618
+ """Validate IAM policies from file(s) or director(ies) using Access Analyzer.
619
+
620
+ Args:
621
+ path: Path to policy file/directory, or list of paths
622
+ region: AWS region for Access Analyzer
623
+ policy_type: Type of policy to validate
624
+ profile: AWS profile name (optional)
625
+ recursive: Whether to search directories recursively
626
+ custom_checks: Optional custom check configurations
627
+
628
+ Returns:
629
+ Validation report
630
+
631
+ Raises:
632
+ ValueError: If no policies found
633
+ ClientError: If AWS API calls fail
634
+ """
635
+ # Load policies
636
+ loader = PolicyLoader()
637
+ if isinstance(path, list):
638
+ loaded_policies = loader.load_from_paths(path, recursive=recursive)
639
+ path_description = ", ".join(path)
640
+ else:
641
+ loaded_policies = loader.load_from_path(path, recursive=recursive)
642
+ path_description = path
643
+
644
+ if not loaded_policies:
645
+ raise ValueError(f"No valid IAM policies found in {path_description}")
646
+
647
+ logging.info(f"Loaded {len(loaded_policies)} policies for Access Analyzer validation")
648
+
649
+ # Convert IAMPolicy models to dicts for Access Analyzer
650
+ # Use by_alias=True to export with capitalized field names (Version, Statement, etc.)
651
+ policy_dicts: list[tuple[str, dict[str, Any]]] = [
652
+ (file_path, policy.model_dump(by_alias=True, exclude_none=True))
653
+ for file_path, policy in loaded_policies
654
+ ]
655
+
656
+ # Validate with Access Analyzer
657
+ validator = AccessAnalyzerValidator(
658
+ region=region,
659
+ policy_type=policy_type,
660
+ profile=profile,
661
+ )
662
+
663
+ results = validator.validate_policies(policy_dicts, custom_checks=custom_checks)
664
+ report = validator.generate_report(results)
665
+
666
+ return report