iam-policy-validator 1.7.2__py3-none-any.whl → 1.8.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 (38) hide show
  1. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.8.0.dist-info}/METADATA +22 -6
  2. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.8.0.dist-info}/RECORD +38 -35
  3. iam_validator/__version__.py +1 -1
  4. iam_validator/checks/__init__.py +5 -3
  5. iam_validator/checks/action_condition_enforcement.py +61 -23
  6. iam_validator/checks/action_resource_matching.py +6 -2
  7. iam_validator/checks/action_validation.py +1 -1
  8. iam_validator/checks/condition_key_validation.py +1 -1
  9. iam_validator/checks/condition_type_mismatch.py +6 -6
  10. iam_validator/checks/policy_structure.py +577 -0
  11. iam_validator/checks/policy_type_validation.py +48 -32
  12. iam_validator/checks/principal_validation.py +65 -133
  13. iam_validator/checks/resource_validation.py +8 -8
  14. iam_validator/checks/sensitive_action.py +7 -3
  15. iam_validator/checks/service_wildcard.py +2 -2
  16. iam_validator/checks/set_operator_validation.py +11 -11
  17. iam_validator/checks/sid_uniqueness.py +8 -4
  18. iam_validator/checks/trust_policy_validation.py +512 -0
  19. iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
  20. iam_validator/checks/utils/wildcard_expansion.py +1 -1
  21. iam_validator/checks/wildcard_action.py +3 -1
  22. iam_validator/checks/wildcard_resource.py +3 -1
  23. iam_validator/commands/validate.py +6 -12
  24. iam_validator/core/__init__.py +1 -2
  25. iam_validator/core/access_analyzer.py +1 -1
  26. iam_validator/core/access_analyzer_report.py +2 -2
  27. iam_validator/core/aws_fetcher.py +45 -43
  28. iam_validator/core/check_registry.py +83 -79
  29. iam_validator/core/config/condition_requirements.py +69 -17
  30. iam_validator/core/config/defaults.py +58 -52
  31. iam_validator/core/config/service_principals.py +40 -3
  32. iam_validator/core/ignore_patterns.py +297 -0
  33. iam_validator/core/models.py +15 -5
  34. iam_validator/core/policy_checks.py +31 -472
  35. iam_validator/core/policy_loader.py +27 -4
  36. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.8.0.dist-info}/WHEEL +0 -0
  37. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.8.0.dist-info}/entry_points.txt +0 -0
  38. {iam_policy_validator-1.7.2.dist-info → iam_policy_validator-1.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -30,6 +30,10 @@ async def execute_policy(
30
30
  """
31
31
  issues = []
32
32
 
33
+ # Handle policies with no statements
34
+ if not policy.statement:
35
+ return issues
36
+
33
37
  # Check if any statement has Principal
34
38
  has_any_principal = any(
35
39
  stmt.principal is not None or stmt.not_principal is not None for stmt in policy.statement
@@ -37,24 +41,36 @@ async def execute_policy(
37
41
 
38
42
  # If policy has Principal but type is IDENTITY_POLICY (default), provide helpful info
39
43
  if has_any_principal and policy_type == "IDENTITY_POLICY":
44
+ # Check if it's a trust policy
45
+ from iam_validator.checks.policy_structure import is_trust_policy
46
+
47
+ if is_trust_policy(policy):
48
+ hint_msg = (
49
+ "Policy contains assume role actions - this is a TRUST POLICY. "
50
+ "Use --policy-type TRUST_POLICY for proper validation (suppresses missing Resource warnings, "
51
+ "enables trust-specific validation)"
52
+ )
53
+ suggestion_msg = "iam-validator validate --path <file> --policy-type TRUST_POLICY"
54
+ else:
55
+ hint_msg = "Policy contains Principal element - this suggests it's a RESOURCE POLICY. Use --policy-type RESOURCE_POLICY"
56
+ suggestion_msg = "iam-validator validate --path <file> --policy-type RESOURCE_POLICY"
57
+
40
58
  issues.append(
41
59
  ValidationIssue(
42
60
  severity="info",
43
61
  issue_type="policy_type_hint",
44
- message="Policy contains Principal element - this suggests it's a resource policy. "
45
- "Use --policy-type RESOURCE_POLICY for proper validation.",
62
+ message=hint_msg,
46
63
  statement_index=0,
47
64
  statement_sid=None,
48
65
  line_number=None,
49
- suggestion="If this is a resource policy (S3 bucket policy, SNS topic policy, etc.), "
50
- "run validation with: iam-validator validate --path <file> --policy-type RESOURCE_POLICY",
66
+ suggestion=suggestion_msg,
51
67
  )
52
68
  )
53
69
  # Don't run further checks if we're just hinting
54
70
  return issues
55
71
 
56
- # Resource policies MUST have Principal
57
- if policy_type == "RESOURCE_POLICY":
72
+ # Resource policies and Trust policies MUST have Principal
73
+ if policy_type in ("RESOURCE_POLICY", "TRUST_POLICY"):
58
74
  for idx, statement in enumerate(policy.statement):
59
75
  has_principal = statement.principal is not None or statement.not_principal is not None
60
76
 
@@ -63,13 +79,13 @@ async def execute_policy(
63
79
  ValidationIssue(
64
80
  severity="error",
65
81
  issue_type="missing_principal",
66
- message="Resource policy statement missing required Principal element. "
82
+ message="Resource policy statement missing required `Principal` element. "
67
83
  "Resource-based policies (S3 bucket policies, SNS topic policies, etc.) "
68
- "must include a Principal element to specify who can access the resource.",
84
+ "must include a `Principal` element to specify who can access the resource.",
69
85
  statement_index=idx,
70
86
  statement_sid=statement.sid,
71
87
  line_number=statement.line_number,
72
- suggestion="Add a Principal element to specify who can access this resource.\n"
88
+ suggestion="Add a `Principal` element to specify who can access this resource.\n"
73
89
  "Example:\n"
74
90
  "```json\n"
75
91
  "{\n"
@@ -94,14 +110,14 @@ async def execute_policy(
94
110
  ValidationIssue(
95
111
  severity="warning",
96
112
  issue_type="unexpected_principal",
97
- message="Identity policy should not contain Principal element. "
113
+ message="Identity policy should not contain `Principal` element. "
98
114
  "Identity-based policies (attached to IAM users, groups, or roles) "
99
- "do not need a Principal element because the principal is implicit "
115
+ "do not need a `Principal` element because the principal is implicit "
100
116
  "(the entity the policy is attached to).",
101
117
  statement_index=idx,
102
118
  statement_sid=statement.sid,
103
119
  line_number=statement.line_number,
104
- suggestion="Remove the Principal element from this identity policy statement.\n"
120
+ suggestion="Remove the `Principal` element from this identity policy statement.\n"
105
121
  "Example:\n"
106
122
  "```json\n"
107
123
  "{\n"
@@ -123,13 +139,13 @@ async def execute_policy(
123
139
  ValidationIssue(
124
140
  severity="error",
125
141
  issue_type="invalid_principal",
126
- message="Service Control Policy must not contain Principal element. "
142
+ message="Service Control Policy must not contain `Principal` element. "
127
143
  "Service Control Policies (SCPs) in AWS Organizations do not support "
128
- "the Principal element. They apply to all principals in the organization or OU.",
144
+ "the `Principal` element. They apply to all principals in the organization or OU.",
129
145
  statement_index=idx,
130
146
  statement_sid=statement.sid,
131
147
  line_number=statement.line_number,
132
- suggestion="Remove the Principal element from this SCP statement.\n"
148
+ suggestion="Remove the `Principal` element from this SCP statement.\n"
133
149
  "Example:\n"
134
150
  "```json\n"
135
151
  "{\n"
@@ -177,7 +193,7 @@ async def execute_policy(
177
193
  ValidationIssue(
178
194
  severity="error",
179
195
  issue_type="invalid_rcp_not_principal",
180
- message="Resource Control Policy must not contain NotPrincipal element. "
196
+ message="Resource Control Policy must not contain `NotPrincipal` element. "
181
197
  "RCPs only support Principal with value '*'. Use Condition elements "
182
198
  "to restrict specific principals.",
183
199
  statement_index=idx,
@@ -192,13 +208,13 @@ async def execute_policy(
192
208
  ValidationIssue(
193
209
  severity="error",
194
210
  issue_type="missing_rcp_principal",
195
- message="Resource Control Policy statement must have Principal: '*'. "
196
- "RCPs require the Principal element with value '*'. Use Condition "
211
+ message="Resource Control Policy statement must have `Principal`: '*'. "
212
+ "RCPs require the `Principal` element with value '*'. Use `Condition` "
197
213
  "elements to restrict specific principals.",
198
214
  statement_index=idx,
199
215
  statement_sid=statement.sid,
200
216
  line_number=statement.line_number,
201
- suggestion='Add Principal: "*" to this RCP statement.',
217
+ suggestion='Add `Principal: "*"` to this RCP statement.',
202
218
  )
203
219
  )
204
220
  elif statement.principal != "*":
@@ -209,13 +225,13 @@ async def execute_policy(
209
225
  ValidationIssue(
210
226
  severity="error",
211
227
  issue_type="invalid_rcp_principal",
212
- message="Resource Control Policy Principal must be '*'. "
213
- f"Found: {statement.principal}. RCPs can only specify '*' in the "
214
- "Principal element. Use Condition elements to restrict specific principals.",
228
+ message=f'Resource Control Policy `Principal` must be `"*"`. '
229
+ f'Found: `{statement.principal}`. RCPs can only specify `"*"` in the '
230
+ "`Principal` element. Use `Condition` elements to restrict specific principals.",
215
231
  statement_index=idx,
216
232
  statement_sid=statement.sid,
217
233
  line_number=statement.line_number,
218
- suggestion='Change Principal to "*" and use Condition elements to restrict access.',
234
+ suggestion='Change `Principal` to `"*"` and use `Condition` elements to restrict access.',
219
235
  )
220
236
  )
221
237
 
@@ -234,13 +250,13 @@ async def execute_policy(
234
250
  ValidationIssue(
235
251
  severity="error",
236
252
  issue_type="invalid_rcp_wildcard_action",
237
- message="Resource Control Policy must not use '*' alone in Action element. "
238
- "Customer-managed RCPs cannot use '*' as the action wildcard. "
239
- "Use service-specific wildcards like 's3:*' instead.",
253
+ message="Resource Control Policy must not use `*` alone in `Action` element. "
254
+ "Customer-managed RCPs cannot use `*` as the action wildcard. "
255
+ "Use service-specific wildcards like `s3:*` instead.",
240
256
  statement_index=idx,
241
257
  statement_sid=statement.sid,
242
258
  line_number=statement.line_number,
243
- suggestion="Replace '*' with service-specific actions from supported "
259
+ suggestion="Replace `*` with service-specific actions from supported "
244
260
  f"services: {', '.join(sorted(rcp_supported_services))}",
245
261
  )
246
262
  )
@@ -275,12 +291,12 @@ async def execute_policy(
275
291
  ValidationIssue(
276
292
  severity="error",
277
293
  issue_type="invalid_rcp_not_action",
278
- message="Resource Control Policy must not contain NotAction element. "
279
- "RCPs do not support NotAction. Use Action element instead.",
294
+ message="Resource Control Policy must not contain `NotAction` element. "
295
+ "RCPs do not support `NotAction`. Use `Action` element instead.",
280
296
  statement_index=idx,
281
297
  statement_sid=statement.sid,
282
298
  line_number=statement.line_number,
283
- suggestion="Replace NotAction with Action element listing the specific "
299
+ suggestion="Replace `NotAction` with `Action` element listing the specific "
284
300
  "actions to deny.",
285
301
  )
286
302
  )
@@ -294,11 +310,11 @@ async def execute_policy(
294
310
  ValidationIssue(
295
311
  severity="error",
296
312
  issue_type="missing_rcp_resource",
297
- message="Resource Control Policy statement must have Resource or NotResource element.",
313
+ message="Resource Control Policy statement must have `Resource` or `NotResource` element.",
298
314
  statement_index=idx,
299
315
  statement_sid=statement.sid,
300
316
  line_number=statement.line_number,
301
- suggestion='Add Resource: "*" or specify specific resource ARNs.',
317
+ suggestion='Add `Resource: "*"` or specify specific resource ARNs.',
302
318
  )
303
319
  )
304
320
 
@@ -4,36 +4,34 @@ Validates Principal elements in resource-based policies for security best practi
4
4
  This check enforces:
5
5
  - Blocked principals (e.g., public access via "*")
6
6
  - Allowed principals whitelist (optional)
7
- - Required conditions for specific principals (simple format)
8
- - Rich condition requirements for principals (advanced format with all_of/any_of)
7
+ - Rich condition requirements for principals (supports any_of/all_of/none_of)
9
8
  - Service principal validation
10
9
 
11
- Only runs for RESOURCE_POLICY type policies.
12
-
13
- Configuration supports TWO formats:
14
-
15
- 1. Simple format (backward compatible):
16
- require_conditions_for:
17
- "*": ["aws:SourceArn", "aws:SourceAccount"]
18
- "arn:aws:iam::*:root": ["aws:PrincipalOrgID"]
19
-
20
- 2. Advanced format with rich condition requirements:
21
- principal_condition_requirements:
22
- - principals:
23
- - "*"
24
- severity: critical
25
- required_conditions:
26
- all_of:
27
- - condition_key: "aws:SourceArn"
28
- description: "Limit by source ARN"
29
- - condition_key: "aws:SourceAccount"
30
-
31
- - principals:
32
- - "arn:aws:iam::*:root"
33
- required_conditions:
34
- - condition_key: "aws:PrincipalOrgID"
35
- expected_value: "o-xxxxx"
36
- operator: "StringEquals"
10
+ Only runs for RESOURCE_POLICY and TRUST_POLICY types.
11
+
12
+ Configuration format:
13
+
14
+ principal_condition_requirements:
15
+ - principals:
16
+ - "*" # Can be a list of principal patterns
17
+ severity: critical # Optional: override default severity
18
+ required_conditions:
19
+ any_of: # At least ONE of these conditions must be present
20
+ - condition_key: "aws:SourceArn"
21
+ description: "Limit by source ARN"
22
+ - condition_key: "aws:SourceAccount"
23
+ expected_value: "123456789012" # Optional: validate specific value
24
+ operator: "StringEquals" # Optional: validate specific operator
25
+
26
+ - principals:
27
+ - "arn:aws:iam::*:root"
28
+ required_conditions:
29
+ all_of: # ALL of these conditions must be present
30
+ - condition_key: "aws:PrincipalOrgID"
31
+ expected_value: "o-xxxxx"
32
+ - condition_key: "aws:SourceAccount"
33
+
34
+ Supports: any_of, all_of, none_of, and expected_value (single value or list)
37
35
  """
38
36
 
39
37
  import fnmatch
@@ -41,6 +39,7 @@ from typing import Any
41
39
 
42
40
  from iam_validator.core.aws_fetcher import AWSServiceFetcher
43
41
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
42
+ from iam_validator.core.config.service_principals import is_aws_service_principal
44
43
  from iam_validator.core.models import Statement, ValidationIssue
45
44
 
46
45
 
@@ -83,22 +82,13 @@ class PrincipalValidationCheck(PolicyCheck):
83
82
  if statement.principal is None and statement.not_principal is None:
84
83
  return issues
85
84
 
86
- # Get configuration
85
+ # Get configuration (defaults match defaults.py)
87
86
  blocked_principals = config.config.get("blocked_principals", ["*"])
88
87
  allowed_principals = config.config.get("allowed_principals", [])
89
- require_conditions_for = config.config.get("require_conditions_for", {})
90
88
  principal_condition_requirements = config.config.get("principal_condition_requirements", [])
91
- allowed_service_principals = config.config.get(
92
- "allowed_service_principals",
93
- [
94
- "cloudfront.amazonaws.com",
95
- "s3.amazonaws.com",
96
- "sns.amazonaws.com",
97
- "lambda.amazonaws.com",
98
- "logs.amazonaws.com",
99
- "events.amazonaws.com",
100
- ],
101
- )
89
+ # Default: "aws:*" allows ALL AWS service principals (*.amazonaws.com)
90
+ # This matches the default in defaults.py:251
91
+ allowed_service_principals = config.config.get("allowed_service_principals", ["aws:*"])
102
92
 
103
93
  # Extract principals from statement
104
94
  principals = self._extract_principals(statement)
@@ -117,8 +107,8 @@ class PrincipalValidationCheck(PolicyCheck):
117
107
  statement_index=statement_idx,
118
108
  statement_sid=statement.sid,
119
109
  line_number=statement.line_number,
120
- suggestion=f"Remove the principal `{principal}` or add appropriate conditions to restrict access. "
121
- "Consider using more specific principals instead of wildcards.",
110
+ suggestion=f"Remove the `Principal` `{principal}` or add appropriate `Condition`s to restrict access. "
111
+ "Consider using more specific `Principal`s instead of `*` (wildcard).",
122
112
  )
123
113
  )
124
114
  continue
@@ -131,42 +121,18 @@ class PrincipalValidationCheck(PolicyCheck):
131
121
  ValidationIssue(
132
122
  severity=self.get_severity(config),
133
123
  issue_type="unauthorized_principal",
134
- message=f"Principal not in allowed list: `{principal}`. "
124
+ message=f"`Principal` not in allowed list: `{principal}`. "
135
125
  f"Only principals in the `allowed_principals` whitelist are permitted.",
136
126
  statement_index=statement_idx,
137
127
  statement_sid=statement.sid,
138
128
  line_number=statement.line_number,
139
- suggestion=f"Add '{principal}' to the allowed_principals list in your config, "
140
- "or use a principal that matches an allowed pattern.",
129
+ suggestion=f"Add `{principal}` to the `allowed_principals` list in your config, "
130
+ "or use a `Principal` that matches an allowed pattern.",
141
131
  )
142
132
  )
143
133
  continue
144
134
 
145
- # Check simple format: require_conditions_for (backward compatible)
146
- required_conditions = self._get_required_conditions(principal, require_conditions_for)
147
- if required_conditions:
148
- missing_conditions = self._check_required_conditions(statement, required_conditions)
149
- if missing_conditions:
150
- issues.append(
151
- ValidationIssue(
152
- severity=self.get_severity(config),
153
- issue_type="missing_principal_conditions",
154
- message=f"Principal `{principal}` requires conditions: {', '.join(f'`{c}`' for c in missing_conditions)}. "
155
- f"This principal must have these condition keys to restrict access.",
156
- statement_index=statement_idx,
157
- statement_sid=statement.sid,
158
- line_number=statement.line_number,
159
- suggestion=f"Add conditions to restrict access:\n"
160
- f"Example:\n"
161
- f'"Condition": {{\n'
162
- f' "StringEquals": {{\n'
163
- f' "{missing_conditions[0]}": "value"\n'
164
- f" }}\n"
165
- f"}}",
166
- )
167
- )
168
-
169
- # Check advanced format: principal_condition_requirements
135
+ # Check principal_condition_requirements (supports any_of/all_of/none_of)
170
136
  if principal_condition_requirements:
171
137
  condition_issues = self._validate_principal_condition_requirements(
172
138
  statement,
@@ -197,7 +163,7 @@ class PrincipalValidationCheck(PolicyCheck):
197
163
  principals.append(statement.principal)
198
164
  elif isinstance(statement.principal, dict):
199
165
  # Dict with AWS, Service, Federated, etc.
200
- for key, value in statement.principal.items():
166
+ for _, value in statement.principal.items():
201
167
  if isinstance(value, str):
202
168
  principals.append(value)
203
169
  elif isinstance(value, list):
@@ -208,7 +174,7 @@ class PrincipalValidationCheck(PolicyCheck):
208
174
  if isinstance(statement.not_principal, str):
209
175
  principals.append(statement.not_principal)
210
176
  elif isinstance(statement.not_principal, dict):
211
- for key, value in statement.not_principal.items():
177
+ for _, value in statement.not_principal.items():
212
178
  if isinstance(value, str):
213
179
  principals.append(value)
214
180
  elif isinstance(value, list):
@@ -224,13 +190,17 @@ class PrincipalValidationCheck(PolicyCheck):
224
190
  Args:
225
191
  principal: The principal to check
226
192
  blocked_list: List of blocked principal patterns
227
- service_whitelist: List of allowed service principals
193
+ service_whitelist: List of allowed service principals (supports "aws:*" for all AWS services)
228
194
 
229
195
  Returns:
230
196
  True if the principal is blocked
231
197
  """
232
- # Service principals are never blocked
233
- if principal in service_whitelist:
198
+ # Check if service_whitelist contains "aws:*" (allow all AWS service principals)
199
+ if "aws:*" in service_whitelist and is_aws_service_principal(principal):
200
+ return False
201
+
202
+ # Service principals in explicit whitelist are never blocked
203
+ if is_aws_service_principal(principal) and principal in service_whitelist:
234
204
  return False
235
205
 
236
206
  # Check against blocked list (supports wildcards)
@@ -253,13 +223,17 @@ class PrincipalValidationCheck(PolicyCheck):
253
223
  Args:
254
224
  principal: The principal to check
255
225
  allowed_list: List of allowed principal patterns
256
- service_whitelist: List of allowed service principals
226
+ service_whitelist: List of allowed service principals (supports "aws:*" for all AWS services)
257
227
 
258
228
  Returns:
259
229
  True if the principal is allowed
260
230
  """
261
- # Service principals are always allowed
262
- if principal in service_whitelist:
231
+ # Check if service_whitelist contains "aws:*" (allow all AWS service principals)
232
+ if "aws:*" in service_whitelist and is_aws_service_principal(principal):
233
+ return True
234
+
235
+ # Service principals in explicit whitelist are always allowed
236
+ if is_aws_service_principal(principal) and principal in service_whitelist:
263
237
  return True
264
238
 
265
239
  # Check against allowed list (supports wildcards)
@@ -274,52 +248,6 @@ class PrincipalValidationCheck(PolicyCheck):
274
248
 
275
249
  return False
276
250
 
277
- def _get_required_conditions(
278
- self, principal: str, requirements: dict[str, list[str]]
279
- ) -> list[str]:
280
- """Get required condition keys for a principal.
281
-
282
- Args:
283
- principal: The principal to check
284
- requirements: Dict mapping principal patterns to required condition keys
285
-
286
- Returns:
287
- List of required condition keys
288
- """
289
- for pattern, condition_keys in requirements.items():
290
- # Special case: "*" pattern should only match literal "*" (public access)
291
- if pattern == "*":
292
- if principal == "*":
293
- return condition_keys
294
- elif fnmatch.fnmatch(principal, pattern):
295
- return condition_keys
296
- return []
297
-
298
- def _check_required_conditions(
299
- self, statement: Statement, required_keys: list[str]
300
- ) -> list[str]:
301
- """Check if statement has required condition keys.
302
-
303
- Args:
304
- statement: The statement to check
305
- required_keys: List of required condition keys
306
-
307
- Returns:
308
- List of missing condition keys
309
- """
310
- if not statement.condition:
311
- return required_keys
312
-
313
- # Flatten all condition keys from all condition operators
314
- present_keys = set()
315
- for operator_conditions in statement.condition.values():
316
- if isinstance(operator_conditions, dict):
317
- present_keys.update(operator_conditions.keys())
318
-
319
- # Find missing keys
320
- missing = [key for key in required_keys if key not in present_keys]
321
- return missing
322
-
323
251
  def _validate_principal_condition_requirements(
324
252
  self,
325
253
  statement: Statement,
@@ -473,6 +401,7 @@ class PrincipalValidationCheck(PolicyCheck):
473
401
  # Create a combined error for any_of
474
402
  condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
475
403
  severity = requirement.get("severity", self.get_severity(config))
404
+ matching_principals_str = ", ".join(f"`{p}`" for p in matching_principals)
476
405
  issues.append(
477
406
  ValidationIssue(
478
407
  severity=severity,
@@ -480,8 +409,8 @@ class PrincipalValidationCheck(PolicyCheck):
480
409
  statement_index=statement_idx,
481
410
  issue_type="missing_principal_condition_any_of",
482
411
  message=(
483
- f"Principals {matching_principals} require at least ONE of these conditions: "
484
- f"{', '.join(condition_keys)}"
412
+ f"`Principal`s `{matching_principals_str}` require at least ONE of these conditions: "
413
+ f"{', '.join(f'`{c}`' for c in condition_keys)}"
485
414
  ),
486
415
  suggestion=self._build_any_of_suggestion(any_of),
487
416
  line_number=statement.line_number,
@@ -637,12 +566,14 @@ class PrincipalValidationCheck(PolicyCheck):
637
566
  condition_key, description, example, expected_value, operator
638
567
  )
639
568
 
569
+ matching_principals_formatted = ", ".join(f"`{p}`" for p in matching_principals)
570
+
640
571
  return ValidationIssue(
641
572
  severity=severity,
642
573
  statement_sid=statement.sid,
643
574
  statement_index=statement_idx,
644
575
  issue_type="missing_principal_condition",
645
- message=f"{message_prefix} Principal(s) {matching_principals} require condition '{condition_key}'",
576
+ message=f"{message_prefix} Principal(s) {matching_principals_formatted} require condition `{condition_key}`",
646
577
  suggestion=suggestion_text,
647
578
  example=example_code,
648
579
  line_number=statement.line_number,
@@ -723,11 +654,11 @@ class PrincipalValidationCheck(PolicyCheck):
723
654
  description = cond.get("description", "")
724
655
  expected_value = cond.get("expected_value")
725
656
 
726
- option = f"\nOption {i}: {condition_key}"
657
+ option = f"\n- **Option {i}**: `{condition_key}`"
727
658
  if description:
728
659
  option += f" - {description}"
729
660
  if expected_value is not None:
730
- option += f" (value: {expected_value})"
661
+ option += f" (value: `{expected_value}`)"
731
662
 
732
663
  suggestions.append(option)
733
664
 
@@ -759,11 +690,12 @@ class PrincipalValidationCheck(PolicyCheck):
759
690
  description = condition_requirement.get("description", "")
760
691
  expected_value = condition_requirement.get("expected_value")
761
692
 
762
- message = f"FORBIDDEN: Principal(s) {matching_principals} must NOT have condition '{condition_key}'"
693
+ matching_principals_str = ", ".join(f"`{p}`" for p in matching_principals)
694
+ message = f"FORBIDDEN: `Principal`s `{matching_principals_str}` must NOT have `Condition` `{condition_key}`"
763
695
  if expected_value is not None:
764
- message += f" with value '{expected_value}'"
696
+ message += f" with value `{expected_value}`"
765
697
 
766
- suggestion = f"Remove the '{condition_key}' condition from the statement"
698
+ suggestion = f"Remove the `{condition_key}` `Condition` from the statement"
767
699
  if description:
768
700
  suggestion += f". {description}"
769
701
 
@@ -75,7 +75,7 @@ class ResourceValidationCheck(PolicyCheck):
75
75
  issue_type="invalid_resource",
76
76
  message=f"Resource ARN exceeds maximum length ({len(resource)} > {MAX_ARN_LENGTH}): {resource[:100]}...",
77
77
  resource=resource[:100] + "...",
78
- suggestion="ARN is too long and may be invalid",
78
+ suggestion="`ARN` is too long and may be invalid",
79
79
  line_number=line_number,
80
80
  )
81
81
  )
@@ -101,9 +101,9 @@ class ResourceValidationCheck(PolicyCheck):
101
101
  statement_sid=statement_sid,
102
102
  statement_index=statement_idx,
103
103
  issue_type="invalid_resource",
104
- message=f"Invalid ARN format even after normalizing template variables: {resource}",
104
+ message=f"Invalid `ARN` format even after normalizing template variables: `{resource}`",
105
105
  resource=resource,
106
- suggestion="ARN should follow format: arn:partition:service:region:account-id:resource (template variables like ${aws_account_id} are supported)",
106
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource` (template variables like `${aws_account_id}` are supported)",
107
107
  line_number=line_number,
108
108
  )
109
109
  )
@@ -114,13 +114,13 @@ class ResourceValidationCheck(PolicyCheck):
114
114
  statement_sid=statement_sid,
115
115
  statement_index=statement_idx,
116
116
  issue_type="invalid_resource",
117
- message=f"Invalid ARN format: {resource}",
117
+ message=f"Invalid `ARN` format: `{resource}`",
118
118
  resource=resource,
119
- suggestion="ARN should follow format: arn:partition:service:region:account-id:resource",
119
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource`",
120
120
  line_number=line_number,
121
121
  )
122
122
  )
123
- except Exception:
123
+ except Exception: # pylint: disable=broad-exception-caught
124
124
  # If regex matching fails (shouldn't happen with length check), treat as invalid
125
125
  issues.append(
126
126
  ValidationIssue(
@@ -128,9 +128,9 @@ class ResourceValidationCheck(PolicyCheck):
128
128
  statement_sid=statement_sid,
129
129
  statement_index=statement_idx,
130
130
  issue_type="invalid_resource",
131
- message=f"Could not validate ARN format: {resource}",
131
+ message=f"Could not validate `ARN` format: `{resource}`",
132
132
  resource=resource,
133
- suggestion="ARN validation failed - may contain unexpected characters",
133
+ suggestion="`ARN` validation failed - may contain unexpected characters",
134
134
  line_number=line_number,
135
135
  )
136
136
  )
@@ -85,9 +85,9 @@ class SensitiveActionCheck(PolicyCheck):
85
85
  # Generic ABAC fallback for uncategorized actions
86
86
  return (
87
87
  "Add IAM conditions to limit when this action can be used. Use ABAC for scalability:\n"
88
- "• Match principal tags to resource tags (aws:PrincipalTag/<tag-name> = aws:ResourceTag/<tag-name>)\n"
89
- "• Require MFA (aws:MultiFactorAuthPresent = true)\n"
90
- "• Restrict by IP (aws:SourceIp) or VPC (aws:SourceVpc)",
88
+ "• Match principal tags to resource tags (`aws:PrincipalTag/<tag-name>` = `aws:ResourceTag/<tag-name>`)\n"
89
+ "• Require MFA (`aws:MultiFactorAuthPresent` = `true`)\n"
90
+ "• Restrict by IP (`aws:SourceIp`) or VPC (`aws:SourceVpc`)",
91
91
  '"Condition": {\n'
92
92
  ' "StringEquals": {\n'
93
93
  ' "aws:PrincipalTag/owner": "${aws:ResourceTag/owner}"\n'
@@ -192,6 +192,10 @@ class SensitiveActionCheck(PolicyCheck):
192
192
  del policy_file, fetcher # Not used in current implementation
193
193
  issues = []
194
194
 
195
+ # Handle policies with no statements
196
+ if not policy.statement:
197
+ return []
198
+
195
199
  # Collect all actions from all Allow statements across the entire policy
196
200
  all_actions: set[str] = set()
197
201
  statement_map: dict[
@@ -51,11 +51,11 @@ class ServiceWildcardCheck(PolicyCheck):
51
51
  # Get message template and replace placeholders
52
52
  message_template = config.config.get(
53
53
  "message",
54
- "Service-level wildcard '{action}' grants all permissions for {service} service",
54
+ "Service-level wildcard `{action}` grants all permissions for `{service}` service",
55
55
  )
56
56
  suggestion_template = config.config.get(
57
57
  "suggestion",
58
- "Consider specifying explicit actions instead of '{action}'. If you need multiple actions, list them individually or use more specific wildcards like '{service}:Get*' or '{service}:List*'.",
58
+ "Consider specifying explicit actions instead of `{action}`. If you need multiple actions, list them individually or use more specific wildcards like `{service}:Get*` or `{service}:List*`.",
59
59
  )
60
60
  example_template = config.config.get("example", "")
61
61