iam-policy-validator 1.11.1__py3-none-any.whl → 1.13.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iam-policy-validator
3
- Version: 1.11.1
3
+ Version: 1.13.0
4
4
  Summary: Validate AWS IAM policies for correctness and security using AWS Service Reference API
5
5
  Project-URL: Homepage, https://github.com/boogy/iam-policy-validator
6
6
  Project-URL: Documentation, https://github.com/boogy/iam-policy-validator/tree/main/docs
@@ -1,8 +1,8 @@
1
1
  iam_validator/__init__.py,sha256=xHdUASOxFHwEXfT_GSr_KrkLlnxZ-pAAr1wW1PwAGko,693
2
2
  iam_validator/__main__.py,sha256=to_nz3n_IerJpVVZZ6WSFlFR5s_06J0csfPOTfQZG8g,197
3
- iam_validator/__version__.py,sha256=VA38cDITf2Rm6FSdOiIMZHEocdI2rmYOPTSCCAIrJ-M,374
3
+ iam_validator/__version__.py,sha256=Gh6xEIE-FdIaY_5D-vLQn-lzIOGKaeGM8vbVmUci1V4,374
4
4
  iam_validator/checks/__init__.py,sha256=OTkPnmlelu4YjMO8krjhu2wXiTV72RzopA5u1SfPQA0,1990
5
- iam_validator/checks/action_condition_enforcement.py,sha256=6LJfO7DCgf10rtPjaZ5P4fmb5hfxJUBS5w1CrOiCu5Q,52442
5
+ iam_validator/checks/action_condition_enforcement.py,sha256=aXCB9hZ7OxucUyJjgW6Y6X1dWPeUAFJIBjz6G_lTWsI,60380
6
6
  iam_validator/checks/action_resource_matching.py,sha256=WiGJmCIJfx5yituMjZxpKmk-99N6nK20ueN02ddy9oM,19296
7
7
  iam_validator/checks/action_validation.py,sha256=QXfNamcstQIO41zNed1-bCmXYkXdV77owu8G2cZ09-A,2517
8
8
  iam_validator/checks/condition_key_validation.py,sha256=QJjG82wxvjdG2m-YuEzAjKRRiWaaPkf_LChdUTvm9g4,3919
@@ -14,7 +14,7 @@ iam_validator/checks/policy_structure.py,sha256=9eR8EEcERKcc5n7D3_LmFIQyDNzVV5Me
14
14
  iam_validator/checks/policy_type_validation.py,sha256=z4RiAvmPhtrf6Gj3z1Ln4dDFWnFclsokVL7x-YhkMiM,15986
15
15
  iam_validator/checks/principal_validation.py,sha256=jusBVEA-sHHft3Kfq_YdvPUgX3cBnxKqC1zhth74kCU,27691
16
16
  iam_validator/checks/resource_validation.py,sha256=G_Pfh3WZ6-C3KTk3XPpUKhOESwIO5ISgbsUXc-aK1SE,5988
17
- iam_validator/checks/sensitive_action.py,sha256=ckyb2n47hKuyr1smC4_Q0dkcdngfhaMueyesQcNE_6k,14912
17
+ iam_validator/checks/sensitive_action.py,sha256=vLGgFMDpW_CmKnKiQcR5pyAZRaDQtqeAAgS3D4CSPDU,18911
18
18
  iam_validator/checks/service_wildcard.py,sha256=ycggiozWm1Z4lkWsDlooMEvRJflzLxZkihQDPZ9G_zw,3949
19
19
  iam_validator/checks/set_operator_validation.py,sha256=FyxZ7qWlp9-ABzZaRRkxRP_Hws7Re7qZgeQCCM9sJAM,7258
20
20
  iam_validator/checks/sid_uniqueness.py,sha256=vfpk88b9G9OApxtrotABI2mPXvGd_C_X4gJKeqIURlk,5968
@@ -22,7 +22,7 @@ iam_validator/checks/trust_policy_validation.py,sha256=a8Sm2xu3gFOHLd7rXDl-ibqiL
22
22
  iam_validator/checks/wildcard_action.py,sha256=CyURgURDt2fQT2468LK813RupQ3WWvpmvLVLjUZf9QQ,1960
23
23
  iam_validator/checks/wildcard_resource.py,sha256=lRNZN7f3ZQrvnbGdVDCefUQF8lESIMoXVfhIgpln3mM,6679
24
24
  iam_validator/checks/utils/__init__.py,sha256=j0X4ibUB6RGx2a-kNoJnlVZwHfoEvzZsIeTmJIAoFzA,45
25
- iam_validator/checks/utils/policy_level_checks.py,sha256=hZjexXgxuELf-wrO-JwVK8VzP8oRHK3sk0PyaG7QVfI,7070
25
+ iam_validator/checks/utils/policy_level_checks.py,sha256=pr-uLo-otB612YLZ-rd8W5Kl9ENaTHuzTNOUhQaULKc,7593
26
26
  iam_validator/checks/utils/sensitive_action_matcher.py,sha256=qDXcJa_2sCJu9pBbjDlI7x5lPtLRc6jQCpKPMheCOJQ,11215
27
27
  iam_validator/checks/utils/wildcard_expansion.py,sha256=3W13hlyWcP2wJ6w-BwM887VOnRzglK6Bk3eHMjUtOco,3131
28
28
  iam_validator/commands/__init__.py,sha256=RBEz-Kgt3aRVn_9B1HRy_XgQMIKzlSSQs4Gtg2jQEv8,729
@@ -49,7 +49,7 @@ iam_validator/core/models.py,sha256=FhQ7fpX6T9AOvHsAPlBZL0NmPuPE_xghD3dp4cAGLZw,
49
49
  iam_validator/core/policy_checks.py,sha256=FNVuS2GTffwCjjrlupVIazC172gSxKYAAT_ObV6Apbo,8803
50
50
  iam_validator/core/policy_loader.py,sha256=2KJnXzGg3g9pDXWZHk3DO0xpZnZZ-wXWFEOdQ_naJ8s,17862
51
51
  iam_validator/core/pr_commenter.py,sha256=vDN9meq861nVpno-GyhGX4wnBE1Z7clCIhv6pq9rZGs,22755
52
- iam_validator/core/report.py,sha256=BkhBFZHBKuF5WiUqXuNgEpFxcDCnbVRjzIy9qfezxdk,36071
52
+ iam_validator/core/report.py,sha256=mi4zjlWHRakO2cTek5NCpiP92kUfJe_j5tAhB5yS02Q,36528
53
53
  iam_validator/core/aws_service/__init__.py,sha256=UqMh4HUdGlx2QF5OoueJJ2UlCnhX4QW_x3KeE_bxRQc,735
54
54
  iam_validator/core/aws_service/cache.py,sha256=DPuOOPPJC867KAYgV1e0RyQs_k3mtefMdYli3jPaN64,3589
55
55
  iam_validator/core/aws_service/client.py,sha256=Zv7rIpEFdUCDXKGp3migPDkj8L5eZltgrGe64M2t2Ko,7336
@@ -64,7 +64,7 @@ iam_validator/core/config/aws_global_conditions.py,sha256=gdmMxXGBy95B3uYUG-J7rn
64
64
  iam_validator/core/config/category_suggestions.py,sha256=fopaZ9kXDrsLgi_r0pERrLwgdPPJl5VIiKvXtQK9tj0,8583
65
65
  iam_validator/core/config/condition_requirements.py,sha256=1CeQJfWV-Y2ImW0Mq9YdrgvH-hj9IXe0gVOm3B36Rc8,10655
66
66
  iam_validator/core/config/config_loader.py,sha256=qKD8aR8YAswaFf68pnYJLFNwKznvcc6lNxSQWU3i6SY,17713
67
- iam_validator/core/config/defaults.py,sha256=HYCuVXVRMT-0E8R6g609STFBU_IeMG0fFMHUYXsArAU,34685
67
+ iam_validator/core/config/defaults.py,sha256=AdbqyUlsw4YViOWXFBENN3scoVx98hF6_dUKmfyZHNU,37057
68
68
  iam_validator/core/config/principal_requirements.py,sha256=VCX7fBDgeDTJQyoz7_x7GI7Kf9O1Eu-sbihoHOrKv6o,15105
69
69
  iam_validator/core/config/sensitive_actions.py,sha256=uATDIp_TD3OQQlsYTZp79qd1mSK2Bf9hJ0JwcqLBr84,25344
70
70
  iam_validator/core/config/service_principals.py,sha256=8pys5H_yycVJ9KTyimAKFYBg83Aol2Iri53wiHjtnEM,3959
@@ -93,8 +93,8 @@ iam_validator/utils/__init__.py,sha256=NveA2F3G1E6-ANZzFr7J6Q6u5mogvMp862iFokmYu
93
93
  iam_validator/utils/cache.py,sha256=wOQKOBeoG6QqC5f0oXcHz63Cjtu_-SsSS-0pTSwyAiM,3254
94
94
  iam_validator/utils/regex.py,sha256=xHoMECttb7qaMhts-c9b0GIxdhHNZTt-UBr7wNhWfzg,6219
95
95
  iam_validator/utils/terminal.py,sha256=FsRaRMH_JAyDgXWBCOgOEhbS89cs17HCmKYoughq5io,724
96
- iam_policy_validator-1.11.1.dist-info/METADATA,sha256=yEdzYv7iyjewXjI59L_Jy5la1Gl49V6rObTECdOGfgY,34456
97
- iam_policy_validator-1.11.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
98
- iam_policy_validator-1.11.1.dist-info/entry_points.txt,sha256=8HtWd8O7mvPiPdZR5YbzY8or_qcqLM4-pKaFdhtFT8M,62
99
- iam_policy_validator-1.11.1.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
100
- iam_policy_validator-1.11.1.dist-info/RECORD,,
96
+ iam_policy_validator-1.13.0.dist-info/METADATA,sha256=DHdEaa1wjR4XRrkk0F_oTFI1v9AbeeQsheg2QS3ojKw,34456
97
+ iam_policy_validator-1.13.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
98
+ iam_policy_validator-1.13.0.dist-info/entry_points.txt,sha256=8HtWd8O7mvPiPdZR5YbzY8or_qcqLM4-pKaFdhtFT8M,62
99
+ iam_policy_validator-1.13.0.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
100
+ iam_policy_validator-1.13.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -3,7 +3,7 @@
3
3
  This file is the single source of truth for the package version.
4
4
  """
5
5
 
6
- __version__ = "1.11.1"
6
+ __version__ = "1.13.0"
7
7
  # Parse version, handling pre-release suffixes like -rc, -alpha, -beta
8
8
  _version_base = __version__.split("-", maxsplit=1)[0] # Remove pre-release suffix if present
9
9
  __version_info__ = tuple(int(part) for part in _version_base.split("."))
@@ -1,130 +1,21 @@
1
- """
2
- Action-Specific Condition Enforcement Check
3
-
4
- This check ensures that specific actions have required conditions.
5
- Supports ALL types of conditions: MFA, IP, VPC, time, tags, encryption, etc.
6
-
7
- The entire policy is scanned once, checking all statements for matching actions.
8
-
9
- ACTION MATCHING MODES:
10
- - Simple list: Checks each statement for any of the specified actions
11
- Example: actions: ["iam:PassRole", "iam:CreateUser"]
12
-
13
- - any_of: Finds statements that contain ANY of the specified actions
14
- Example: actions: {any_of: ["iam:CreateUser", "iam:AttachUserPolicy"]}
15
-
16
- - all_of: Finds statements that contain ALL specified actions (overly permissive detection)
17
- Example: actions: {all_of: ["iam:CreateAccessKey", "iam:UpdateAccessKey"]}
18
-
19
- - none_of: Flags statements that contain forbidden actions
20
- Example: actions: {none_of: ["iam:DeleteUser", "s3:DeleteBucket"]}
21
-
22
- Common use cases:
23
- - iam:PassRole must have iam:PassedToService condition
24
- - Sensitive actions must have MFA conditions
25
- - Actions must have source IP restrictions
26
- - Resources must have required tags
27
- - Combine multiple conditions (MFA + IP + Tags)
28
- - Detect overly permissive statements (all_of)
29
- - Ensure privilege escalation combinations are protected
30
-
31
- Configuration in iam-validator.yaml:
32
-
33
- checks:
34
- action_condition_enforcement:
35
- enabled: true
36
- severity: high
37
- description: "Enforce specific conditions for specific actions"
38
-
39
- action_condition_requirements:
40
- # BASIC: Simple action with required condition
41
- - actions:
42
- - "iam:PassRole"
43
- required_conditions:
44
- - condition_key: "iam:PassedToService"
45
- description: "Specify which AWS services can use the passed role"
46
-
47
- # MFA + IP restrictions
48
- - actions:
49
- - "iam:DeleteUser"
50
- required_conditions:
51
- all_of:
52
- - condition_key: "aws:MultiFactorAuthPresent"
53
- expected_value: true
54
- - condition_key: "aws:SourceIp"
55
-
56
- # EC2 with TAGS + MFA + Region
57
- - actions:
58
- - "ec2:RunInstances"
59
- required_conditions:
60
- all_of:
61
- - condition_key: "aws:MultiFactorAuthPresent"
62
- expected_value: true
63
- - condition_key: "aws:RequestTag/Environment"
64
- operator: "StringEquals"
65
- expected_value: ["Production", "Staging", "Development"]
66
- - condition_key: "aws:RequestTag/Owner"
67
- - condition_key: "aws:RequestedRegion"
68
- expected_value: ["us-east-1", "us-west-2"]
69
-
70
- # Principal-to-resource tag matching
71
- - actions:
72
- - "ec2:RunInstances"
73
- required_conditions:
74
- - condition_key: "aws:ResourceTag/owner"
75
- operator: "StringEquals"
76
- expected_value: "${aws:PrincipalTag/owner}"
77
- description: "Resource owner must match principal's owner tag"
78
-
79
- # Complex: all_of + any_of for actions and conditions
80
- - actions:
81
- any_of:
82
- - "cloudformation:CreateStack"
83
- - "cloudformation:UpdateStack"
84
- required_conditions:
85
- all_of:
86
- - condition_key: "aws:MultiFactorAuthPresent"
87
- expected_value: true
88
- - condition_key: "aws:RequestTag/Environment"
89
- any_of:
90
- - condition_key: "aws:SourceIp"
91
- - condition_key: "aws:SourceVpce"
92
-
93
- # none_of for conditions: Ensure certain conditions are NOT present
94
- - actions:
95
- - "s3:GetObject"
96
- required_conditions:
97
- none_of:
98
- - condition_key: "aws:SecureTransport"
99
- expected_value: false
100
- description: "Ensure insecure transport is never allowed"
101
-
102
- # any_of for actions: If ANY statement grants privilege escalation actions, require MFA
103
- - actions:
104
- any_of:
105
- - "iam:CreateUser"
106
- - "iam:AttachUserPolicy"
107
- - "iam:PutUserPolicy"
108
- required_conditions:
109
- - condition_key: "aws:MultiFactorAuthPresent"
110
- expected_value: true
111
- description: "Privilege escalation actions require MFA"
112
- severity: "critical"
113
-
114
- # all_of for actions: Flag statements that contain BOTH dangerous actions (overly permissive)
115
- - actions:
116
- all_of:
117
- - "iam:CreateAccessKey"
118
- - "iam:UpdateAccessKey"
119
- severity: "critical"
120
- description: "Statement grants both CreateAccessKey and UpdateAccessKey - too permissive"
121
-
122
- # none_of for actions: Flag if forbidden actions are present
123
- - actions:
124
- none_of:
125
- - "iam:DeleteUser"
126
- - "s3:DeleteBucket"
127
- description: "These dangerous actions should never be used"
1
+ """Action-Specific Condition Enforcement Check.
2
+
3
+ Ensures specific actions have required IAM conditions (MFA, IP, tags, etc.).
4
+
5
+ Action Matching Modes:
6
+ - Simple list: actions: ["iam:PassRole"]
7
+ - any_of: Require conditions if ANY action matches
8
+ - all_of: Require conditions if ALL actions present (overly permissive detection)
9
+ - none_of: Flag forbidden actions
10
+
11
+ Merge Strategies (merge_strategy setting):
12
+ - append (default): User + defaults both apply
13
+ - user_only: Disable ALL defaults, use only user requirements
14
+ - per_action_override: User replaces defaults for matching actions
15
+ - replace_all: User replaces all if provided
16
+ - defaults_only: Ignore user, use only defaults
17
+
18
+ For full documentation, see: docs/condition-requirements.md
128
19
  """
129
20
 
130
21
  import re
@@ -132,6 +23,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
132
23
 
133
24
  from iam_validator.core.aws_service import AWSServiceFetcher
134
25
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
26
+ from iam_validator.core.ignore_patterns import IgnorePatternMatcher
135
27
  from iam_validator.core.models import Statement, ValidationIssue
136
28
  from iam_validator.utils.regex import compile_and_cache
137
29
 
@@ -181,21 +73,11 @@ class ActionConditionEnforcementCheck(PolicyCheck):
181
73
  Returns:
182
74
  List of ValidationIssue objects found by this check
183
75
  """
184
- del policy_file, kwargs # Not used in current implementation
76
+ del kwargs # Not used in current implementation
185
77
  issues = []
186
78
 
187
- # Get action condition requirements from config
188
- # Support legacy keys for backward compatibility:
189
- # - "requirements" (current/preferred)
190
- # - "action_condition_requirements" (legacy)
191
- # - "policy_level_requirements" (legacy)
192
- requirements = config.config.get(
193
- "requirements",
194
- config.config.get(
195
- "action_condition_requirements",
196
- config.config.get("policy_level_requirements", []),
197
- ),
198
- )
79
+ # Get action condition requirements using configurable merge strategy
80
+ requirements = self._get_merged_requirements(config, policy_file)
199
81
 
200
82
  if not requirements:
201
83
  return issues
@@ -211,16 +93,261 @@ class ActionConditionEnforcementCheck(PolicyCheck):
211
93
  if uses_logical_operators:
212
94
  # Policy-wide detection (all_of/any_of/none_of)
213
95
  policy_issues = await self._check_policy_wide(policy, requirement, fetcher, config)
96
+ # Filter by requirement-level ignore_patterns
97
+ policy_issues = self._filter_requirement_issues(
98
+ policy_issues, requirement.get("ignore_patterns", []), policy_file
99
+ )
214
100
  issues.extend(policy_issues)
215
101
  else:
216
102
  # Per-statement check (simple list)
217
103
  statement_issues = await self._check_per_statement(
218
104
  policy, requirement, fetcher, config
219
105
  )
106
+ # Filter by requirement-level ignore_patterns
107
+ statement_issues = self._filter_requirement_issues(
108
+ statement_issues, requirement.get("ignore_patterns", []), policy_file
109
+ )
220
110
  issues.extend(statement_issues)
221
111
 
222
112
  return issues
223
113
 
114
+ def _get_merged_requirements(
115
+ self,
116
+ config: CheckConfig,
117
+ policy_file: str,
118
+ ) -> list[dict[str, Any]]:
119
+ """
120
+ Get merged requirements based on configured merge strategy.
121
+
122
+ Supports multiple merge strategies to control how user requirements
123
+ interact with default requirements:
124
+ - "per_action_override": User requirements replace defaults for matching actions (default)
125
+ - "append": User requirements added to defaults (both apply)
126
+ - "replace_all": User requirements completely replace ALL defaults
127
+ - "defaults_only": Ignore user requirements, use only defaults
128
+ - "user_only": Ignore defaults, use only user requirements
129
+
130
+ Args:
131
+ config: Check configuration containing requirements and merge strategy
132
+ policy_file: Path to the policy file being checked (for ignore_patterns)
133
+
134
+ Returns:
135
+ Merged list of requirements based on strategy
136
+ """
137
+ # Get default and user requirements
138
+ default_requirements = config.config.get(
139
+ "requirements",
140
+ config.config.get("policy_level_requirements", []),
141
+ )
142
+ user_requirements = config.config.get("action_condition_requirements")
143
+
144
+ # Get merge strategy (default: append - both defaults and user requirements apply)
145
+ merge_strategy = config.config.get("merge_strategy", "append")
146
+
147
+ # For user_only, replace_all, and per_action_override:
148
+ # Filter user requirements by ignore_patterns BEFORE merging
149
+ # For append and defaults_only: ignore_patterns on user requirements still apply
150
+ if user_requirements:
151
+ active_user_requirements = self._filter_requirements_by_filepath(
152
+ user_requirements, policy_file
153
+ )
154
+ else:
155
+ active_user_requirements = []
156
+
157
+ # Apply merge strategy
158
+ if merge_strategy == "user_only":
159
+ # Use ONLY user requirements - no defaults at all
160
+ # If a user requirement is filtered by ignore_patterns, it's simply not checked
161
+ return active_user_requirements
162
+
163
+ elif merge_strategy == "defaults_only":
164
+ # Use ONLY defaults - ignore all user requirements
165
+ return default_requirements
166
+
167
+ elif merge_strategy == "replace_all":
168
+ # User requirements completely replace ALL defaults (if user provided any)
169
+ # If no user requirements provided, fall back to defaults
170
+ if user_requirements: # Check original, not filtered
171
+ return active_user_requirements
172
+ return default_requirements
173
+
174
+ elif merge_strategy == "per_action_override":
175
+ # User requirements replace defaults for MATCHING actions only
176
+ # Non-matching defaults are kept
177
+ # Note: We use the ORIGINAL user_requirements to determine which actions
178
+ # are "user-defined" (even if filtered out by ignore_patterns)
179
+ return self._merge_per_action_override(
180
+ default_requirements, user_requirements or [], active_user_requirements
181
+ )
182
+
183
+ else: # "append" (default)
184
+ # Both defaults AND user requirements apply
185
+ # User requirements are added on top of defaults
186
+ return default_requirements + active_user_requirements
187
+
188
+ def _filter_requirements_by_filepath(
189
+ self,
190
+ requirements: list[dict[str, Any]],
191
+ policy_file: str,
192
+ ) -> list[dict[str, Any]]:
193
+ """
194
+ Filter out requirements that should be ignored for this file.
195
+
196
+ This handles ignore_patterns at the requirement level BEFORE merging,
197
+ allowing defaults to apply when user requirements are ignored.
198
+
199
+ Args:
200
+ requirements: List of requirements to filter
201
+ policy_file: Path to the policy file being checked
202
+
203
+ Returns:
204
+ Filtered list of requirements (excluding ignored ones)
205
+
206
+ Example:
207
+ User defines: iam:CreateRole with ignore_patterns: [".*test/.*"]
208
+ When checking test/policy.json:
209
+ - User requirement is filtered out
210
+ - Default iam:CreateRole requirement can apply instead
211
+ """
212
+ active_reqs = []
213
+
214
+ for req in requirements:
215
+ ignore_patterns = req.get("ignore_patterns", [])
216
+
217
+ if not ignore_patterns:
218
+ # No ignore patterns - include this requirement
219
+ active_reqs.append(req)
220
+ continue
221
+
222
+ # Check if any ignore pattern matches this file
223
+ should_ignore = self._should_ignore_filepath(policy_file, ignore_patterns)
224
+
225
+ if not should_ignore:
226
+ active_reqs.append(req)
227
+
228
+ return active_reqs
229
+
230
+ def _should_ignore_filepath(
231
+ self,
232
+ filepath: str,
233
+ ignore_patterns: list[dict[str, Any]],
234
+ ) -> bool:
235
+ """
236
+ Check if filepath matches any of the ignore patterns.
237
+
238
+ Only checks filepath-based patterns (filepath, filepath_regex).
239
+ This is used for filtering requirements before merging.
240
+
241
+ Args:
242
+ filepath: Path to the policy file
243
+ ignore_patterns: List of ignore pattern dictionaries
244
+
245
+ Returns:
246
+ True if filepath matches any pattern
247
+ """
248
+ for pattern in ignore_patterns:
249
+ # Only check filepath-based patterns
250
+ if "filepath" in pattern or "filepath_regex" in pattern:
251
+ regex_pattern = pattern.get("filepath") or pattern.get("filepath_regex")
252
+ if regex_pattern:
253
+ compiled = compile_and_cache(regex_pattern)
254
+ if compiled and compiled.search(filepath):
255
+ return True
256
+ return False
257
+
258
+ def _merge_per_action_override(
259
+ self,
260
+ default_requirements: list[dict[str, Any]],
261
+ all_user_requirements: list[dict[str, Any]],
262
+ active_user_requirements: list[dict[str, Any]],
263
+ ) -> list[dict[str, Any]]:
264
+ """
265
+ Merge user requirements with defaults on a per-action basis.
266
+
267
+ User requirements override defaults for matching actions.
268
+ Defaults are kept for actions not specified by user.
269
+
270
+ Key behavior with ignore_patterns:
271
+ - If user defines a requirement for action X with ignore_patterns
272
+ - And the current file matches the ignore_patterns
273
+ - Then: The user requirement is SKIPPED (not applied)
274
+ - AND: The default for action X is ALSO skipped (user "owns" this action)
275
+ - Result: No check for action X on this file
276
+
277
+ Args:
278
+ default_requirements: Default requirements from system config
279
+ all_user_requirements: ALL user requirements (before ignore_patterns filtering)
280
+ active_user_requirements: User requirements after ignore_patterns filtering
281
+
282
+ Returns:
283
+ Merged list of requirements
284
+ """
285
+ # Build a set of actions that user has customized (from ALL user requirements)
286
+ # This determines which defaults to exclude
287
+ user_actions = set()
288
+ for req in all_user_requirements:
289
+ actions = req.get("actions", [])
290
+ # Handle both single action and list of actions
291
+ if isinstance(actions, str):
292
+ user_actions.add(actions)
293
+ else:
294
+ user_actions.update(actions)
295
+
296
+ # Start with ACTIVE user requirements (filtered by ignore_patterns)
297
+ merged = list(active_user_requirements)
298
+
299
+ # Add defaults that don't conflict with user requirements
300
+ for default_req in default_requirements:
301
+ default_actions = default_req.get("actions", [])
302
+ # Handle both single action and list of actions
303
+ if isinstance(default_actions, str):
304
+ default_actions = [default_actions]
305
+
306
+ # Check if any of the default actions are customized by user
307
+ has_overlap = any(action in user_actions for action in default_actions)
308
+
309
+ if not has_overlap:
310
+ # No overlap - keep this default requirement
311
+ merged.append(default_req)
312
+
313
+ return merged
314
+
315
+ def _filter_requirement_issues(
316
+ self,
317
+ issues: list[ValidationIssue],
318
+ ignore_patterns: list[dict[str, Any]],
319
+ filepath: str,
320
+ ) -> list[ValidationIssue]:
321
+ """
322
+ Filter issues based on requirement-level ignore patterns.
323
+
324
+ This allows each requirement within action_condition_enforcement to have its own
325
+ ignore patterns, enabling fine-grained control over which findings to suppress.
326
+
327
+ Args:
328
+ issues: List of validation issues to filter
329
+ ignore_patterns: List of ignore pattern dictionaries for this requirement
330
+ filepath: Path to the policy file being checked
331
+
332
+ Returns:
333
+ Filtered list of issues (issues matching ignore patterns are removed)
334
+
335
+ Example:
336
+ A requirement can ignore specific files while other requirements check them:
337
+ - actions: ["iam:CreateRole"]
338
+ required_conditions: [...]
339
+ ignore_patterns:
340
+ - filepath_regex: ".*modules/iam-openid.*"
341
+ """
342
+ if not ignore_patterns:
343
+ return issues
344
+
345
+ return [
346
+ issue
347
+ for issue in issues
348
+ if not IgnorePatternMatcher.should_ignore_issue(issue, filepath, ignore_patterns)
349
+ ]
350
+
224
351
  async def _check_policy_wide(
225
352
  self,
226
353
  policy: "IAMPolicy",
@@ -955,33 +1082,52 @@ class ActionConditionEnforcementCheck(PolicyCheck):
955
1082
  )
956
1083
 
957
1084
  if not any_present:
958
- # Create a combined error for any_of
959
- # Handle both simple conditions and nested all_of
960
- condition_keys = []
961
- for cond in any_of:
962
- if "all_of" in cond:
963
- # Nested all_of - collect all condition keys
964
- nested_keys = [
965
- c.get("condition_key", "unknown") for c in cond["all_of"]
966
- ]
967
- condition_keys.append(f"({' + '.join(f'`{k}`' for k in nested_keys)})")
968
- else:
969
- # Simple condition
970
- condition_keys.append(f"`{cond.get('condition_key', 'unknown')}`")
971
- condition_keys_formatted = " OR ".join(condition_keys)
972
- matching_actions_formatted = ", ".join(f"`{a}`" for a in matching_actions)
1085
+ # Check if requirement has custom message/suggestion/example
1086
+ custom_message = requirement.get("message") if requirement else None
1087
+ custom_suggestion = requirement.get("suggestion") if requirement else None
1088
+ custom_example = requirement.get("example") if requirement else None
1089
+
1090
+ if custom_message:
1091
+ # Use fully custom message/suggestion/example from requirement
1092
+ message = custom_message
1093
+ suggestion = custom_suggestion or ""
1094
+ example = custom_example or ""
1095
+ else:
1096
+ # Generate default message and build suggestion from conditions
1097
+ condition_keys = []
1098
+ for cond in any_of:
1099
+ if "all_of" in cond:
1100
+ # Nested all_of - collect all condition keys
1101
+ nested_keys = [
1102
+ c.get("condition_key", "unknown") for c in cond["all_of"]
1103
+ ]
1104
+ condition_keys.append(
1105
+ f"({' + '.join(f'`{k}`' for k in nested_keys)})"
1106
+ )
1107
+ else:
1108
+ # Simple condition
1109
+ condition_keys.append(f"`{cond.get('condition_key', 'unknown')}`")
1110
+ condition_keys_formatted = " OR ".join(condition_keys)
1111
+ matching_actions_formatted = ", ".join(f"`{a}`" for a in matching_actions)
1112
+
1113
+ message = (
1114
+ f"Actions {matching_actions_formatted} require at least ONE of these conditions: "
1115
+ f"{condition_keys_formatted}"
1116
+ )
1117
+
1118
+ # Build suggestion and examples from conditions
1119
+ suggestion, example = self._build_any_of_suggestion(any_of)
1120
+
973
1121
  issues.append(
974
1122
  ValidationIssue(
975
1123
  severity=self.get_severity(config),
976
1124
  statement_sid=statement.sid,
977
1125
  statement_index=statement_idx,
978
1126
  issue_type="missing_required_condition_any_of",
979
- message=(
980
- f"Actions {matching_actions_formatted} require at least ONE of these conditions: "
981
- f"{condition_keys_formatted}"
982
- ),
1127
+ message=message,
983
1128
  action=", ".join(matching_actions),
984
- suggestion=self._build_any_of_suggestion(any_of),
1129
+ suggestion=suggestion,
1130
+ example=example if example else None,
985
1131
  line_number=statement.line_number,
986
1132
  )
987
1133
  )
@@ -1180,12 +1326,24 @@ class ActionConditionEnforcementCheck(PolicyCheck):
1180
1326
 
1181
1327
  return suggestion, example_code
1182
1328
 
1183
- def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
1184
- """Build suggestion for any_of conditions."""
1329
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> tuple[str, str]:
1330
+ """Build suggestion and combined examples for any_of conditions.
1331
+
1332
+ Always uses clean formatting without "Option X" prefixes.
1333
+ Uses either:
1334
+ - 'message' field if provided (custom message)
1335
+ - 'description' field if provided (displays as: `condition_key` - description)
1336
+ - Just 'condition_key' if neither message nor description provided
1337
+
1338
+ Returns:
1339
+ Tuple of (suggestion_text, combined_examples)
1340
+ """
1185
1341
  suggestions = []
1342
+ examples = []
1343
+
1186
1344
  suggestions.append("Add at least ONE of these conditions:")
1187
1345
 
1188
- for i, cond in enumerate(any_of_conditions, 1):
1346
+ for cond in any_of_conditions:
1189
1347
  # Handle nested all_of blocks
1190
1348
  if "all_of" in cond:
1191
1349
  # Nested all_of - show all required conditions together
@@ -1193,35 +1351,55 @@ class ActionConditionEnforcementCheck(PolicyCheck):
1193
1351
  condition_keys = [c.get("condition_key", "unknown") for c in all_of_list]
1194
1352
  condition_keys_formatted = " + ".join(f"`{k}`" for k in condition_keys)
1195
1353
 
1196
- option = f"\n- **Option {i}**: {condition_keys_formatted} (both required)"
1197
-
1198
- # Use description from first condition or combine them
1199
- descriptions = [
1200
- c.get("description", "") for c in all_of_list if c.get("description")
1201
- ]
1202
- if descriptions:
1203
- option += f" - {descriptions[0]}"
1204
-
1205
- # Show example from first condition that has one
1354
+ # Check for custom message first
1355
+ custom_message = cond.get("message")
1356
+ if custom_message:
1357
+ suggestions.append(f"\n- {custom_message}")
1358
+ else:
1359
+ # Use description from first condition or combine them
1360
+ descriptions = [
1361
+ c.get("description", "") for c in all_of_list if c.get("description")
1362
+ ]
1363
+ if descriptions:
1364
+ suggestions.append(f"\n- {condition_keys_formatted} - {descriptions[0]}")
1365
+ else:
1366
+ suggestions.append(f"\n- {condition_keys_formatted} (both required)")
1367
+
1368
+ # Collect example from first condition that has one
1206
1369
  for c in all_of_list:
1207
1370
  if c.get("example"):
1208
- # Example will be shown separately, just note it's available
1371
+ examples.append(c["example"])
1209
1372
  break
1210
1373
  else:
1211
- # Simple condition (original behavior)
1212
- condition_key = cond.get("condition_key", "unknown")
1213
- description = cond.get("description", "")
1214
- expected_value = cond.get("expected_value")
1215
-
1216
- option = f"\n- **Option {i}**: `{condition_key}`"
1217
- if description:
1218
- option += f" - {description}"
1219
- if expected_value is not None:
1220
- option += f" (value: `{expected_value}`)"
1221
-
1222
- suggestions.append(option)
1223
-
1224
- return "".join(suggestions)
1374
+ # Simple condition - check for message, description, or just condition_key
1375
+ custom_message = cond.get("message")
1376
+ if custom_message:
1377
+ # Use custom message directly
1378
+ suggestions.append(f"\n- {custom_message}")
1379
+ else:
1380
+ # Use description if available
1381
+ condition_key = cond.get("condition_key", "unknown")
1382
+ description = cond.get("description", "")
1383
+ expected_value = cond.get("expected_value")
1384
+
1385
+ if description:
1386
+ # Format: - `condition_key` - description
1387
+ suggestions.append(f"\n- `{condition_key}` - {description}")
1388
+ else:
1389
+ # Format: - `condition_key` (with expected value if present)
1390
+ suggestion_line = f"\n- `{condition_key}`"
1391
+ if expected_value is not None:
1392
+ suggestion_line += f" (value: `{expected_value}`)"
1393
+ suggestions.append(suggestion_line)
1394
+
1395
+ # Collect example if present (no prefix)
1396
+ if cond.get("example"):
1397
+ examples.append(cond["example"])
1398
+
1399
+ suggestion_text = "".join(suggestions)
1400
+ combined_examples = "\n\n".join(examples) if examples else ""
1401
+
1402
+ return suggestion_text, combined_examples
1225
1403
 
1226
1404
  def _create_none_of_issue(
1227
1405
  self,
@@ -271,6 +271,50 @@ class SensitiveActionCheck(PolicyCheck):
271
271
 
272
272
  return issues
273
273
 
274
+ def _apply_merge_strategy(
275
+ self,
276
+ merge_strategy: str,
277
+ user_config: list[dict] | None,
278
+ default_config: list[dict] | None,
279
+ ) -> list[dict] | None:
280
+ """
281
+ Apply merge strategy to combine user and default sensitive action patterns.
282
+
283
+ Args:
284
+ merge_strategy: One of "per_action_override", "user_only", "append",
285
+ "replace_all", or "defaults_only"
286
+ user_config: User-provided sensitive action patterns (or None)
287
+ default_config: Default sensitive action patterns (or None)
288
+
289
+ Returns:
290
+ Merged list of patterns based on strategy, or None if no patterns
291
+ """
292
+ if merge_strategy == "user_only":
293
+ # Use ONLY user patterns, completely ignore defaults
294
+ return user_config
295
+
296
+ elif merge_strategy == "defaults_only":
297
+ # Use ONLY defaults, ignore user patterns
298
+ return default_config
299
+
300
+ elif merge_strategy == "append":
301
+ # Combine both (defaults first, then user)
302
+ result = []
303
+ if default_config:
304
+ result.extend(default_config)
305
+ if user_config:
306
+ result.extend(user_config)
307
+ return result if result else None
308
+
309
+ elif merge_strategy == "replace_all":
310
+ # User replaces all if provided, otherwise use defaults
311
+ return user_config if user_config else default_config
312
+
313
+ else: # "per_action_override" (default)
314
+ # If user provides patterns, use them; otherwise use defaults
315
+ # This is the legacy behavior
316
+ return user_config if user_config else default_config
317
+
274
318
  async def execute_policy(
275
319
  self,
276
320
  policy: "IAMPolicy",
@@ -319,9 +363,45 @@ class SensitiveActionCheck(PolicyCheck):
319
363
  statement_map[action] = []
320
364
  statement_map[action].append((idx, statement.sid))
321
365
 
322
- # Get configuration for sensitive actions
323
- sensitive_actions_config = config.config.get("sensitive_actions")
324
- sensitive_patterns_config = config.config.get("sensitive_action_patterns")
366
+ # Get configuration for sensitive actions with merge_strategy support
367
+ # merge_strategy options:
368
+ # - "append": Add user patterns ON TOP OF defaults (both apply) - DEFAULT
369
+ # - "user_only": Use ONLY user patterns, disable ALL default privilege escalation patterns
370
+ # - "defaults_only": Ignore user patterns, use only defaults
371
+ # - "replace_all": User patterns completely replace ALL defaults (if provided)
372
+ # - "per_action_override": User patterns replace defaults for matching action combos
373
+ merge_strategy = config.config.get("merge_strategy", "append")
374
+
375
+ # Determine which sensitive_actions patterns to use based on merge_strategy
376
+ # Note: The config.config already contains deep-merged values from defaults + user config
377
+ # For lists like sensitive_actions, user config REPLACES defaults (not merges)
378
+ # So if user provided sensitive_actions, it's already the only value in config.config
379
+ sensitive_actions_config: list[dict] | None = None
380
+ sensitive_patterns_config: list[dict] | None = None
381
+
382
+ if merge_strategy == "user_only":
383
+ # user_only: Disable ALL default patterns
384
+ # If user set merge_strategy: "user_only", they want NO defaults
385
+ # They must explicitly provide sensitive_actions if they want any checks
386
+ # Since we can't distinguish user-provided from defaults after merge,
387
+ # we assume user_only means "no patterns" unless user explicitly provided them
388
+ # (which would have replaced defaults anyway)
389
+ sensitive_actions_config = None
390
+ sensitive_patterns_config = None
391
+
392
+ elif merge_strategy == "defaults_only":
393
+ # Use only defaults - but since config is merged, we use what's there
394
+ # (user would need to NOT provide sensitive_actions to get defaults)
395
+ sensitive_actions_config = config.config.get("sensitive_actions")
396
+ sensitive_patterns_config = config.config.get("sensitive_action_patterns")
397
+
398
+ else:
399
+ # append, replace_all, per_action_override all use the merged config
400
+ # The deep_merge already handled the merging:
401
+ # - If user provided sensitive_actions, it replaced defaults
402
+ # - If user didn't provide it, defaults are in config
403
+ sensitive_actions_config = config.config.get("sensitive_actions")
404
+ sensitive_patterns_config = config.config.get("sensitive_action_patterns")
325
405
 
326
406
  # Check for privilege escalation patterns using all_of logic
327
407
  # We need to check both exact actions and patterns
@@ -98,15 +98,21 @@ def _check_all_of_pattern(
98
98
  Returns:
99
99
  ValidationIssue if privilege escalation detected, None otherwise
100
100
  """
101
+ # Filter out actions that match ignore_patterns BEFORE checking for privilege escalation
102
+ # This allows users to exclude specific actions from privilege escalation detection
103
+ # by adding them to ignore_patterns in sensitive_action config
104
+ filtered_actions = check_config.filter_actions(frozenset(all_actions))
105
+ all_actions_filtered = list(filtered_actions)
106
+
101
107
  matched_actions = []
102
108
 
103
109
  if check_type == "actions":
104
110
  # Exact matching
105
- matched_actions = [a for a in all_actions if a in required_actions]
111
+ matched_actions = [a for a in all_actions_filtered if a in required_actions]
106
112
  else:
107
113
  # Pattern matching - for each pattern, find actions that match
108
114
  for pattern in required_actions:
109
- for action in all_actions:
115
+ for action in all_actions_filtered:
110
116
  try:
111
117
  if re.match(pattern, action):
112
118
  matched_actions.append(action)
@@ -167,6 +173,9 @@ def _check_all_of_pattern(
167
173
  f"Actions found in:\n - {stmt_details}",
168
174
  )
169
175
 
176
+ # Use custom example if provided in item_config
177
+ example = item_config.get("example")
178
+
170
179
  return ValidationIssue(
171
180
  severity=severity,
172
181
  statement_sid=None, # Policy-level issue
@@ -174,6 +183,7 @@ def _check_all_of_pattern(
174
183
  issue_type="privilege_escalation",
175
184
  message=message,
176
185
  suggestion=suggestion,
186
+ example=example,
177
187
  line_number=1, # Policy-level issues point to line 1 (top of policy)
178
188
  )
179
189
 
@@ -519,7 +519,7 @@ DEFAULT_CONFIG = {
519
519
  # Useful when you have specific condition enforcement for certain actions
520
520
  # Example: Ignore iam:PassRole since it's checked by action_condition_enforcement
521
521
  "ignore_patterns": [
522
- {"action_matches": "^iam:PassRole$"},
522
+ {"action": "^iam:PassRole$"},
523
523
  ],
524
524
  # Cross-statement privilege escalation patterns (policy-wide detection)
525
525
  # These patterns detect dangerous action combinations across ANY statements in the policy
@@ -537,7 +537,19 @@ DEFAULT_CONFIG = {
537
537
  "3. Escalate to full account access\n\n"
538
538
  "Mitigation options:\n"
539
539
  "• Remove both of these permissions\n"
540
- "• Add strict IAM conditions (MFA, IP restrictions, force a specific policy with `iam:PolicyARN` condition)\n"
540
+ "• Add strict IAM conditions (IP restrictions, tags, force a specific policy with `iam:PolicyARN` condition)\n"
541
+ ),
542
+ "example": (
543
+ "{\n"
544
+ ' "Condition": {\n'
545
+ ' "StringEquals": {\n'
546
+ ' "iam:PolicyARN": "arn:aws:iam::*:policy/ReadOnlyAccess"\n'
547
+ " },\n"
548
+ ' "IpAddress": {\n'
549
+ ' "aws:SourceIp": ["10.0.0.0/8"]\n'
550
+ " }\n"
551
+ " }\n"
552
+ "}\n"
541
553
  ),
542
554
  },
543
555
  # Role privilege escalation: Create role + attach admin policy
@@ -551,6 +563,23 @@ DEFAULT_CONFIG = {
551
563
  "• Remove both of these permissions\n"
552
564
  "• Add strict IAM conditions with a Permissions Boundary and ABAC Tagging, force a specific policy with `iam:PolicyARN` condition\n"
553
565
  ),
566
+ "example": (
567
+ "{\n"
568
+ ' "Condition": {\n'
569
+ ' "StringEquals": {\n'
570
+ ' "iam:PermissionsBoundary": "arn:aws:iam::*:policy/MaxPermissions"\n'
571
+ " }\n"
572
+ " }\n"
573
+ "}\n"
574
+ "OR\n"
575
+ "{\n"
576
+ ' "Condition": {\n'
577
+ ' "StringEquals": {\n'
578
+ ' "iam:PolicyARN": "arn:aws:iam::*:policy/MaxPermissions"\n'
579
+ " }\n"
580
+ " }\n"
581
+ "}\n"
582
+ ),
554
583
  },
555
584
  # Lambda backdoor: Create/update function + invoke
556
585
  {
@@ -567,6 +596,18 @@ DEFAULT_CONFIG = {
567
596
  "• Require MFA for Lambda function creation\n"
568
597
  "• Use separate policies for creation vs invocation"
569
598
  ),
599
+ "example": (
600
+ "{\n"
601
+ ' "Condition": {\n'
602
+ ' "StringEquals": {\n'
603
+ ' "aws:PrincipalTag/team": "${aws:ResourceTag/team}"\n'
604
+ " },\n"
605
+ ' "SourceIp": {\n'
606
+ ' "aws:SourceIp": ["10.0.0.0/8"]\n'
607
+ " }\n"
608
+ " }\n"
609
+ "}\n"
610
+ ),
570
611
  },
571
612
  # Lambda code modification backdoor
572
613
  {
@@ -581,6 +622,15 @@ DEFAULT_CONFIG = {
581
622
  "• Use separate policies for code updates vs invocation\n"
582
623
  "• Implement code signing for Lambda functions"
583
624
  ),
625
+ "example": (
626
+ "{\n"
627
+ ' "Condition": {\n'
628
+ ' "StringEquals": {\n'
629
+ ' "aws:ResourceAccount": "${aws:PrincipalAccount}"\n'
630
+ " }\n"
631
+ " }\n"
632
+ "}\n"
633
+ ),
584
634
  },
585
635
  # EC2 instance privilege escalation
586
636
  {
@@ -595,6 +645,18 @@ DEFAULT_CONFIG = {
595
645
  "• Limit PassRole to specific low-privilege roles\n"
596
646
  "• Require tagging and ABAC controls"
597
647
  ),
648
+ "example": (
649
+ "{\n"
650
+ ' "Condition": {\n'
651
+ ' "StringEquals": {\n'
652
+ ' "iam:PassedToService": "ec2.amazonaws.com"\n'
653
+ " },\n"
654
+ ' "ArnLike": {\n'
655
+ ' "iam:AssociatedResourceArn": "arn:aws:ec2:*:*:instance/*"\n'
656
+ " }\n"
657
+ " }\n"
658
+ "}\n"
659
+ ),
598
660
  },
599
661
  ],
600
662
  },
@@ -882,6 +882,17 @@ class ReportGenerator:
882
882
  parts.append(f"> 💡 **Suggestion:** {issue.suggestion}")
883
883
  parts.append("")
884
884
 
885
+ # Handle separate example field (if not already in suggestion)
886
+ if issue.example and "\nExample:\n" not in (issue.suggestion or ""):
887
+ parts.append("<details>")
888
+ parts.append("<summary>📖 View Example</summary>")
889
+ parts.append("")
890
+ parts.append("```json")
891
+ parts.append(issue.example)
892
+ parts.append("```")
893
+ parts.append("</details>")
894
+ parts.append("")
895
+
885
896
  return "\n".join(parts)
886
897
 
887
898
  def save_json_report(self, report: ValidationReport, file_path: str) -> None: