iam-policy-validator 1.7.2__py3-none-any.whl → 1.9.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.
Files changed (56) hide show
  1. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/METADATA +127 -6
  2. iam_policy_validator-1.9.0.dist-info/RECORD +95 -0
  3. iam_validator/__init__.py +1 -1
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/__init__.py +5 -3
  6. iam_validator/checks/action_condition_enforcement.py +559 -207
  7. iam_validator/checks/action_resource_matching.py +12 -15
  8. iam_validator/checks/action_validation.py +7 -13
  9. iam_validator/checks/condition_key_validation.py +7 -13
  10. iam_validator/checks/condition_type_mismatch.py +15 -22
  11. iam_validator/checks/full_wildcard.py +9 -13
  12. iam_validator/checks/mfa_condition_check.py +8 -17
  13. iam_validator/checks/policy_size.py +6 -39
  14. iam_validator/checks/policy_structure.py +547 -0
  15. iam_validator/checks/policy_type_validation.py +61 -46
  16. iam_validator/checks/principal_validation.py +71 -148
  17. iam_validator/checks/resource_validation.py +13 -20
  18. iam_validator/checks/sensitive_action.py +15 -18
  19. iam_validator/checks/service_wildcard.py +8 -14
  20. iam_validator/checks/set_operator_validation.py +21 -28
  21. iam_validator/checks/sid_uniqueness.py +16 -42
  22. iam_validator/checks/trust_policy_validation.py +506 -0
  23. iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
  24. iam_validator/checks/utils/wildcard_expansion.py +2 -2
  25. iam_validator/checks/wildcard_action.py +9 -13
  26. iam_validator/checks/wildcard_resource.py +9 -13
  27. iam_validator/commands/cache.py +4 -3
  28. iam_validator/commands/validate.py +15 -9
  29. iam_validator/core/__init__.py +2 -3
  30. iam_validator/core/access_analyzer.py +1 -1
  31. iam_validator/core/access_analyzer_report.py +2 -2
  32. iam_validator/core/aws_fetcher.py +24 -1028
  33. iam_validator/core/aws_service/__init__.py +21 -0
  34. iam_validator/core/aws_service/cache.py +108 -0
  35. iam_validator/core/aws_service/client.py +205 -0
  36. iam_validator/core/aws_service/fetcher.py +612 -0
  37. iam_validator/core/aws_service/parsers.py +149 -0
  38. iam_validator/core/aws_service/patterns.py +51 -0
  39. iam_validator/core/aws_service/storage.py +291 -0
  40. iam_validator/core/aws_service/validators.py +379 -0
  41. iam_validator/core/check_registry.py +165 -93
  42. iam_validator/core/config/condition_requirements.py +69 -17
  43. iam_validator/core/config/defaults.py +58 -52
  44. iam_validator/core/config/service_principals.py +40 -3
  45. iam_validator/core/constants.py +17 -0
  46. iam_validator/core/ignore_patterns.py +297 -0
  47. iam_validator/core/models.py +15 -5
  48. iam_validator/core/policy_checks.py +38 -475
  49. iam_validator/core/policy_loader.py +27 -4
  50. iam_validator/sdk/__init__.py +1 -1
  51. iam_validator/sdk/context.py +1 -1
  52. iam_validator/sdk/helpers.py +1 -1
  53. iam_policy_validator-1.7.2.dist-info/RECORD +0 -84
  54. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/WHEEL +0 -0
  55. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/entry_points.txt +0 -0
  56. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,11 +10,12 @@ This module provides a pluggable check system that allows:
10
10
  """
11
11
 
12
12
  import asyncio
13
- from abc import ABC, abstractmethod
13
+ from abc import ABC
14
14
  from dataclasses import dataclass, field
15
15
  from typing import TYPE_CHECKING, Any
16
16
 
17
- from iam_validator.core.aws_fetcher import AWSServiceFetcher
17
+ from iam_validator.core.aws_service import AWSServiceFetcher
18
+ from iam_validator.core.ignore_patterns import IgnorePatternMatcher
18
19
  from iam_validator.core.models import Statement, ValidationIssue
19
20
 
20
21
  if TYPE_CHECKING:
@@ -36,107 +37,64 @@ class CheckConfig:
36
37
  List of patterns to ignore findings.
37
38
 
38
39
  Each pattern is a dict with optional fields:
39
- - filepath_regex: Regex to match file path
40
- - action_matches: Regex to match action name
41
- - resource_matches: Regex to match resource
42
- - statement_sid: Exact SID to match (or regex if ends with .*)
43
- - condition_key_matches: Regex to match condition key
40
+ - filepath: Regex to match file path
41
+ - action: Regex to match action name
42
+ - resource: Regex to match resource
43
+ - sid: Exact SID to match (or regex if ends with .*)
44
+ - condition_key: Regex to match condition key
44
45
 
45
46
  Multiple fields in one pattern = AND logic
46
47
  Multiple patterns = OR logic (any pattern matches → ignore)
47
48
 
48
49
  Example:
49
50
  ignore_patterns:
50
- - filepath_regex: "test/.*|examples/.*"
51
- - filepath_regex: "policies/readonly-.*"
52
- action_matches: ".*:(Get|List|Describe).*"
53
- - statement_sid: "AllowReadOnlyAccess"
51
+ - filepath: "test/.*|examples/.*"
52
+ - filepath: "policies/readonly-.*"
53
+ action: ".*:(Get|List|Describe).*"
54
+ - sid: "AllowReadOnlyAccess"
54
55
  """
55
56
 
56
57
  def should_ignore(self, issue: ValidationIssue, filepath: str = "") -> bool:
57
58
  """
58
59
  Check if issue should be ignored based on ignore patterns.
59
60
 
61
+ Uses centralized IgnorePatternMatcher for high-performance filtering
62
+ with cached compiled regex patterns.
63
+
60
64
  Args:
61
65
  issue: The validation issue to check
62
66
  filepath: Path to the policy file
63
67
 
64
68
  Returns:
65
69
  True if the issue should be ignored
66
- """
67
- if not self.ignore_patterns:
68
- return False
69
-
70
- import re
71
-
72
- for pattern in self.ignore_patterns:
73
- if self._matches_pattern(pattern, issue, filepath, re):
74
- return True
75
70
 
76
- return False
71
+ Performance:
72
+ - Cached regex compilation (LRU cache)
73
+ - Early exit optimization
74
+ """
75
+ return IgnorePatternMatcher.should_ignore_issue(issue, filepath, self.ignore_patterns)
77
76
 
78
- def _matches_pattern(
79
- self,
80
- pattern: dict[str, Any],
81
- issue: ValidationIssue,
82
- filepath: str,
83
- re_module: Any,
84
- ) -> bool:
77
+ def filter_actions(self, actions: frozenset[str]) -> frozenset[str]:
85
78
  """
86
- Check if issue matches a single ignore pattern.
79
+ Filter actions based on action ignore patterns.
80
+
81
+ Uses centralized IgnorePatternMatcher for high-performance filtering
82
+ with cached compiled regex patterns.
87
83
 
88
- All fields in pattern must match (AND logic).
84
+ This is useful for checks that need to filter a set of actions before
85
+ creating ValidationIssues (e.g., sensitive_action check).
89
86
 
90
87
  Args:
91
- pattern: Pattern dict with optional fields
92
- issue: ValidationIssue to check
93
- filepath: Path to policy file
94
- re_module: re module for regex matching
88
+ actions: Set of actions to filter
95
89
 
96
90
  Returns:
97
- True if all fields in pattern match the issue
91
+ Filtered set of actions (actions matching ignore patterns removed)
92
+
93
+ Performance:
94
+ - Cached regex compilation (LRU cache)
95
+ - Early exit per action on first match
98
96
  """
99
- for field_name, regex_pattern in pattern.items():
100
- actual_value = None
101
-
102
- if field_name == "filepath_regex":
103
- actual_value = filepath
104
- elif field_name == "action_matches":
105
- actual_value = issue.action or ""
106
- elif field_name == "resource_matches":
107
- actual_value = issue.resource or ""
108
- elif field_name == "statement_sid":
109
- # For SID, support both exact match and regex
110
- if isinstance(regex_pattern, str) and "*" in regex_pattern:
111
- # Treat as regex if contains wildcard
112
- actual_value = issue.statement_sid or ""
113
- else:
114
- # Exact match
115
- if issue.statement_sid != regex_pattern:
116
- return False
117
- continue
118
- elif field_name == "condition_key_matches":
119
- actual_value = issue.condition_key or ""
120
- else:
121
- # Unknown field, skip
122
- continue
123
-
124
- # Check regex match (case-insensitive)
125
- if actual_value is None:
126
- return False
127
-
128
- try:
129
- if not re_module.search(
130
- str(regex_pattern),
131
- str(actual_value),
132
- re_module.IGNORECASE,
133
- ):
134
- return False
135
- except re_module.error:
136
- # Invalid regex, don't match
137
- return False
138
-
139
- return True # All fields matched
97
+ return IgnorePatternMatcher.filter_actions(actions, self.ignore_patterns)
140
98
 
141
99
 
142
100
  class PolicyCheck(ABC):
@@ -145,38 +103,101 @@ class PolicyCheck(ABC):
145
103
 
146
104
  To create a custom check:
147
105
  1. Inherit from this class
148
- 2. Implement check_id, description, and execute()
149
- 3. Register with CheckRegistry
106
+ 2. Implement check_id and description (required)
107
+ 3. Implement either execute() OR execute_policy() (or both)
108
+ 4. Register with CheckRegistry
150
109
 
151
- Example:
152
- class MyCustomCheck(PolicyCheck):
153
- check_id = "my_custom_check"
154
- description = "Validates custom compliance rules"
110
+ Two ways to define check_id and description:
111
+
112
+ Option 1 - Class attributes (simpler, recommended for static values):
113
+ from typing import ClassVar
114
+
115
+ class MyCheck(PolicyCheck):
116
+ check_id: ClassVar[str] = "my_check"
117
+ description: ClassVar[str] = "My check description"
155
118
 
156
119
  async def execute(self, statement, statement_idx, fetcher, config):
120
+ return []
121
+
122
+ Note: ClassVar annotation is required for Pylance type checker compatibility.
123
+
124
+ Option 2 - Property decorators (more flexible, supports dynamic values):
125
+ class MyCheck(PolicyCheck):
126
+ @property
127
+ def check_id(self) -> str:
128
+ return "my_check"
129
+
130
+ @property
131
+ def description(self) -> str:
132
+ return "My check description"
133
+
134
+ async def execute(self, statement, statement_idx, fetcher, config):
135
+ return []
136
+
137
+ Statement-level check example:
138
+ from typing import ClassVar
139
+
140
+ class MyStatementCheck(PolicyCheck):
141
+ check_id: ClassVar[str] = "my_statement_check"
142
+ description: ClassVar[str] = "Validates individual statements"
143
+
144
+ async def execute(self, statement, statement_idx, fetcher, config):
145
+ issues = []
146
+ # Your validation logic here
147
+ return issues
148
+
149
+ Policy-level check example:
150
+ from typing import ClassVar
151
+
152
+ class MyPolicyCheck(PolicyCheck):
153
+ check_id: ClassVar[str] = "my_policy_check"
154
+ description: ClassVar[str] = "Validates entire policy"
155
+
156
+ async def execute_policy(self, policy, policy_file, fetcher, config, **kwargs):
157
157
  issues = []
158
158
  # Your validation logic here
159
159
  return issues
160
160
  """
161
161
 
162
162
  @property
163
- @abstractmethod
164
163
  def check_id(self) -> str:
165
164
  """Unique identifier for this check (e.g., 'action_validation')."""
166
- pass
165
+ raise NotImplementedError("Subclasses must define check_id")
167
166
 
168
167
  @property
169
- @abstractmethod
170
168
  def description(self) -> str:
171
169
  """Human-readable description of what this check does."""
172
- pass
170
+ raise NotImplementedError("Subclasses must define description")
173
171
 
174
172
  @property
175
173
  def default_severity(self) -> str:
176
174
  """Default severity level for issues found by this check."""
177
175
  return "warning"
178
176
 
179
- @abstractmethod
177
+ def __init_subclass__(cls, **kwargs):
178
+ """
179
+ Validate that subclasses override at least one execution method.
180
+
181
+ This ensures checks implement either execute() OR execute_policy() (or both).
182
+ If neither is overridden, the check would never produce any results.
183
+ """
184
+ super().__init_subclass__(**kwargs)
185
+
186
+ # Skip validation for abstract classes
187
+ if ABC in cls.__bases__:
188
+ return
189
+
190
+ # Check if at least one method is overridden
191
+ has_execute = cls.execute is not PolicyCheck.execute
192
+ has_execute_policy = cls.execute_policy is not PolicyCheck.execute_policy
193
+
194
+ if not has_execute and not has_execute_policy:
195
+ raise TypeError(
196
+ f"Check '{cls.__name__}' must override at least one of: "
197
+ "execute() for statement-level checks, or "
198
+ "execute_policy() for policy-level checks"
199
+ )
200
+
180
201
  async def execute(
181
202
  self,
182
203
  statement: Statement,
@@ -187,6 +208,10 @@ class PolicyCheck(ABC):
187
208
  """
188
209
  Execute the check on a policy statement.
189
210
 
211
+ This method is called for statement-level checks. If your check only needs
212
+ to examine the entire policy (not individual statements), you can leave this
213
+ as the default implementation and override execute_policy() instead.
214
+
190
215
  Args:
191
216
  statement: The IAM policy statement to check
192
217
  statement_idx: Index of the statement in the policy
@@ -196,7 +221,8 @@ class PolicyCheck(ABC):
196
221
  Returns:
197
222
  List of ValidationIssue objects found by this check
198
223
  """
199
- pass
224
+ del statement, statement_idx, fetcher, config # Unused in default implementation
225
+ return []
200
226
 
201
227
  async def execute_policy(
202
228
  self,
@@ -399,6 +425,10 @@ class CheckRegistry:
399
425
  config = self.get_config(check.check_id)
400
426
  if config:
401
427
  issues = await check.execute(statement, statement_idx, fetcher, config)
428
+ # Inject check_id into each issue
429
+ for issue in issues:
430
+ if issue.check_id is None:
431
+ issue.check_id = check.check_id
402
432
  # Filter issues based on ignore_patterns
403
433
  filtered_issues = [
404
434
  issue for issue in issues if not config.should_ignore(issue, filepath)
@@ -427,7 +457,12 @@ class CheckRegistry:
427
457
  check = enabled_checks[idx]
428
458
  print(f"Warning: Check '{check.check_id}' failed: {result}")
429
459
  elif isinstance(result, list):
460
+ check = enabled_checks[idx]
430
461
  config = configs[idx]
462
+ # Inject check_id into each issue
463
+ for issue in result:
464
+ if issue.check_id is None:
465
+ issue.check_id = check.check_id
431
466
  # Filter issues based on ignore_patterns
432
467
  filtered_issues = [
433
468
  issue for issue in result if not config.should_ignore(issue, filepath)
@@ -475,6 +510,7 @@ class CheckRegistry:
475
510
  policy_file: str,
476
511
  fetcher: AWSServiceFetcher,
477
512
  policy_type: str = "IDENTITY_POLICY",
513
+ **kwargs,
478
514
  ) -> list[ValidationIssue]:
479
515
  """
480
516
  Execute all enabled policy-level checks.
@@ -487,6 +523,7 @@ class CheckRegistry:
487
523
  policy_file: Path to the policy file (for context/reporting)
488
524
  fetcher: AWS service fetcher for API calls
489
525
  policy_type: Type of policy (IDENTITY_POLICY, RESOURCE_POLICY, SERVICE_CONTROL_POLICY)
526
+ **kwargs: Additional arguments to pass to checks (e.g., raw_policy_dict)
490
527
 
491
528
  Returns:
492
529
  List of all ValidationIssue objects from all policy-level checks
@@ -507,34 +544,61 @@ class CheckRegistry:
507
544
  if config:
508
545
  try:
509
546
  issues = await check.execute_policy(
510
- policy, policy_file, fetcher, config, policy_type=policy_type
547
+ policy,
548
+ policy_file,
549
+ fetcher,
550
+ config,
551
+ policy_type=policy_type,
552
+ **kwargs,
511
553
  )
512
- all_issues.extend(issues)
554
+ # Inject check_id into each issue
555
+ for issue in issues:
556
+ if issue.check_id is None:
557
+ issue.check_id = check.check_id
558
+ # Filter issues based on ignore_patterns
559
+ filtered_issues = [
560
+ issue
561
+ for issue in issues
562
+ if not config.should_ignore(issue, policy_file)
563
+ ]
564
+ all_issues.extend(filtered_issues)
513
565
  except Exception as e:
514
566
  print(f"Warning: Check '{check.check_id}' failed: {e}")
515
567
  return all_issues
516
568
 
517
569
  # Execute all policy-level checks in parallel
518
570
  tasks = []
571
+ configs = []
519
572
  for check in policy_level_checks:
520
573
  config = self.get_config(check.check_id)
521
574
  if config:
522
575
  task = check.execute_policy(
523
- policy, policy_file, fetcher, config, policy_type=policy_type
576
+ policy, policy_file, fetcher, config, policy_type=policy_type, **kwargs
524
577
  )
525
578
  tasks.append(task)
579
+ configs.append(config)
526
580
 
527
581
  # Wait for all checks to complete
528
582
  results = await asyncio.gather(*tasks, return_exceptions=True)
529
583
 
530
- # Collect all issues, handling any exceptions
584
+ # Collect all issues, handling any exceptions and applying ignore_patterns
531
585
  for idx, result in enumerate(results):
532
586
  if isinstance(result, Exception):
533
587
  # Log error but continue with other checks
534
588
  check = policy_level_checks[idx]
535
589
  print(f"Warning: Check '{check.check_id}' failed: {result}")
536
590
  elif isinstance(result, list):
537
- all_issues.extend(result)
591
+ check = policy_level_checks[idx]
592
+ config = configs[idx]
593
+ # Inject check_id into each issue
594
+ for issue in result:
595
+ if issue.check_id is None:
596
+ issue.check_id = check.check_id
597
+ # Filter issues based on ignore_patterns
598
+ filtered_issues = [
599
+ issue for issue in result if not config.should_ignore(issue, policy_file)
600
+ ]
601
+ all_issues.extend(filtered_issues)
538
602
 
539
603
  return all_issues
540
604
 
@@ -561,6 +625,11 @@ def create_default_registry(
561
625
  # Import and register built-in checks
562
626
  from iam_validator import checks
563
627
 
628
+ # 0. FUNDAMENTAL STRUCTURE (Must run FIRST - validates basic policy structure)
629
+ registry.register(
630
+ checks.PolicyStructureCheck()
631
+ ) # Policy-level: Validates required fields, conflicts, valid values
632
+
564
633
  # 1. POLICY STRUCTURE (Checks that examine the entire policy, not individual statements)
565
634
  registry.register(
566
635
  checks.SidUniquenessCheck()
@@ -600,6 +669,9 @@ def create_default_registry(
600
669
  registry.register(
601
670
  checks.PrincipalValidationCheck()
602
671
  ) # Principal validation (resource policies)
672
+ registry.register(
673
+ checks.TrustPolicyValidationCheck()
674
+ ) # Trust policy validation (role assumption policies)
603
675
 
604
676
  # Note: policy_type_validation is a standalone function (not a class-based check)
605
677
  # and is called separately in the validation flow
@@ -54,23 +54,75 @@ IAM_PASS_ROLE_REQUIREMENT: Final[dict[str, Any]] = {
54
54
  S3_WRITE_ORG_ID: Final[dict[str, Any]] = {
55
55
  "actions": ["s3:PutObject"],
56
56
  "severity": "medium",
57
- "required_conditions": [
58
- {
59
- "condition_key": "aws:ResourceOrgId",
60
- "description": (
61
- "Require aws:ResourceAccount, aws:ResourceOrgID or aws:ResourceOrgPaths condition(s) for S3 write actions to enforce organization-level access control"
62
- ),
63
- "example": (
64
- "{\n"
65
- ' "Condition": {\n'
66
- ' "StringEquals": {\n'
67
- ' "aws:ResourceOrgId": "${aws:PrincipalOrgID}"\n'
68
- " }\n"
69
- " }\n"
70
- "}"
71
- ),
72
- },
73
- ],
57
+ "required_conditions": {
58
+ "any_of": [
59
+ # Option 1: Use organization-level control with ResourceOrgID
60
+ {
61
+ "all_of": [
62
+ {
63
+ "condition_key": "aws:ResourceOrgID",
64
+ "description": "Restrict S3 write actions to resources within your AWS Organization",
65
+ "expected_value": "${aws:PrincipalOrgID}",
66
+ "example": (
67
+ "{\n"
68
+ ' "Condition": {\n'
69
+ ' "StringEquals": {\n'
70
+ ' "aws:ResourceOrgID": "${aws:PrincipalOrgID}",\n'
71
+ ' "aws:ResourceAccount": "${aws:PrincipalAccount}"\n'
72
+ " }\n"
73
+ " }\n"
74
+ "}"
75
+ ),
76
+ },
77
+ {
78
+ "condition_key": "aws:ResourceAccount",
79
+ "description": "Ensure the S3 resource belongs to the same AWS account as the principal",
80
+ "expected_value": "${aws:PrincipalAccount}",
81
+ },
82
+ ]
83
+ },
84
+ # Option 2: Use organization path-based control
85
+ {
86
+ "all_of": [
87
+ {
88
+ "condition_key": "aws:ResourceOrgPaths",
89
+ "description": "Restrict S3 write actions to resources within your AWS Organization path",
90
+ "expected_value": "${aws:PrincipalOrgPaths}",
91
+ "example": (
92
+ "{\n"
93
+ ' "Condition": {\n'
94
+ ' "StringEquals": {\n'
95
+ ' "aws:ResourceOrgPaths": "${aws:PrincipalOrgPaths}",\n'
96
+ ' "aws:ResourceAccount": "${aws:PrincipalAccount}"\n'
97
+ " }\n"
98
+ " }\n"
99
+ "}"
100
+ ),
101
+ },
102
+ {
103
+ "condition_key": "aws:ResourceAccount",
104
+ "description": "Ensure the S3 resource belongs to the same AWS account as the principal",
105
+ "expected_value": "${aws:PrincipalAccount}",
106
+ },
107
+ ]
108
+ },
109
+ # Option 3: Account-only control (less restrictive, but still secure)
110
+ {
111
+ "condition_key": "aws:ResourceAccount",
112
+ "description": "Restrict S3 write actions to resources within the same AWS account",
113
+ "expected_value": "${aws:PrincipalAccount}",
114
+ "example": (
115
+ "{\n"
116
+ ' "Condition": {\n'
117
+ ' "StringEquals": {\n'
118
+ ' "aws:ResourceAccount": "${aws:PrincipalAccount}"\n'
119
+ " }\n"
120
+ " }\n"
121
+ "}"
122
+ ),
123
+ },
124
+ ],
125
+ },
74
126
  }
75
127
 
76
128
  # IP Restrictions - Source IP requirements