iam-policy-validator 1.13.1__py3-none-any.whl → 1.14.1__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 (45) hide show
  1. {iam_policy_validator-1.13.1.dist-info → iam_policy_validator-1.14.1.dist-info}/METADATA +1 -1
  2. {iam_policy_validator-1.13.1.dist-info → iam_policy_validator-1.14.1.dist-info}/RECORD +45 -39
  3. iam_validator/__version__.py +1 -1
  4. iam_validator/checks/action_condition_enforcement.py +6 -0
  5. iam_validator/checks/action_resource_matching.py +12 -12
  6. iam_validator/checks/action_validation.py +1 -0
  7. iam_validator/checks/condition_key_validation.py +2 -0
  8. iam_validator/checks/condition_type_mismatch.py +3 -0
  9. iam_validator/checks/full_wildcard.py +1 -0
  10. iam_validator/checks/mfa_condition_check.py +2 -0
  11. iam_validator/checks/policy_structure.py +9 -0
  12. iam_validator/checks/policy_type_validation.py +11 -0
  13. iam_validator/checks/principal_validation.py +5 -0
  14. iam_validator/checks/resource_validation.py +4 -0
  15. iam_validator/checks/sensitive_action.py +1 -0
  16. iam_validator/checks/service_wildcard.py +6 -3
  17. iam_validator/checks/set_operator_validation.py +3 -0
  18. iam_validator/checks/sid_uniqueness.py +2 -0
  19. iam_validator/checks/trust_policy_validation.py +3 -0
  20. iam_validator/checks/utils/__init__.py +16 -0
  21. iam_validator/checks/utils/action_parser.py +149 -0
  22. iam_validator/checks/wildcard_action.py +1 -0
  23. iam_validator/checks/wildcard_resource.py +231 -4
  24. iam_validator/commands/analyze.py +19 -1
  25. iam_validator/commands/completion.py +6 -2
  26. iam_validator/commands/validate.py +231 -12
  27. iam_validator/core/aws_service/fetcher.py +21 -9
  28. iam_validator/core/codeowners.py +245 -0
  29. iam_validator/core/config/check_documentation.py +390 -0
  30. iam_validator/core/config/config_loader.py +199 -0
  31. iam_validator/core/config/defaults.py +25 -0
  32. iam_validator/core/constants.py +1 -0
  33. iam_validator/core/diff_parser.py +8 -4
  34. iam_validator/core/finding_fingerprint.py +131 -0
  35. iam_validator/core/formatters/sarif.py +370 -128
  36. iam_validator/core/ignore_processor.py +309 -0
  37. iam_validator/core/ignored_findings.py +400 -0
  38. iam_validator/core/models.py +54 -4
  39. iam_validator/core/policy_loader.py +313 -4
  40. iam_validator/core/pr_commenter.py +223 -22
  41. iam_validator/core/report.py +22 -6
  42. iam_validator/integrations/github_integration.py +881 -123
  43. {iam_policy_validator-1.13.1.dist-info → iam_policy_validator-1.14.1.dist-info}/WHEEL +0 -0
  44. {iam_policy_validator-1.13.1.dist-info → iam_policy_validator-1.14.1.dist-info}/entry_points.txt +0 -0
  45. {iam_policy_validator-1.13.1.dist-info → iam_policy_validator-1.14.1.dist-info}/licenses/LICENSE +0 -0
@@ -262,6 +262,7 @@ class TrustPolicyValidationCheck(PolicyCheck):
262
262
  example=self._get_example_for_action(
263
263
  action, allowed_types[0] if allowed_types else "AWS"
264
264
  ),
265
+ field_name="principal",
265
266
  )
266
267
  )
267
268
 
@@ -312,6 +313,7 @@ class TrustPolicyValidationCheck(PolicyCheck):
312
313
  f"Expected pattern: `{provider_pattern}`\n"
313
314
  f"Found: `{principal}`",
314
315
  example=self._get_provider_example(provider_type),
316
+ field_name="principal",
315
317
  )
316
318
  )
317
319
 
@@ -378,6 +380,7 @@ class TrustPolicyValidationCheck(PolicyCheck):
378
380
  f"Missing: `{missing_list}`\n\n"
379
381
  f"{rule.get('description', '')}",
380
382
  example=self._get_condition_example(action, required_conditions[0]),
383
+ field_name="condition",
381
384
  )
382
385
  )
383
386
 
@@ -1 +1,17 @@
1
1
  """Utility modules for IAM policy checks."""
2
+
3
+ from iam_validator.checks.utils.action_parser import (
4
+ ParsedAction,
5
+ extract_service,
6
+ get_action_case_insensitive,
7
+ is_wildcard_action,
8
+ parse_action,
9
+ )
10
+
11
+ __all__ = [
12
+ "ParsedAction",
13
+ "extract_service",
14
+ "get_action_case_insensitive",
15
+ "is_wildcard_action",
16
+ "parse_action",
17
+ ]
@@ -0,0 +1,149 @@
1
+ """Action parsing utility for IAM policy validation.
2
+
3
+ This module provides a consistent way to parse AWS IAM action names
4
+ (format: service:ActionName) across all validation checks.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import TypeVar
9
+
10
+ # Type variable for generic dictionary value lookup
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ParsedAction:
16
+ """Represents a parsed AWS IAM action.
17
+
18
+ Attributes:
19
+ service: The AWS service prefix (e.g., "s3", "ec2", "iam")
20
+ action_name: The action name (e.g., "GetObject", "DescribeInstances")
21
+ has_wildcard: True if the service or action contains "*"
22
+ original: The original action string as provided
23
+ """
24
+
25
+ service: str
26
+ action_name: str
27
+ has_wildcard: bool
28
+ original: str
29
+
30
+
31
+ def parse_action(action: str) -> ParsedAction | None:
32
+ """Parse an AWS IAM action string into its components.
33
+
34
+ AWS IAM actions follow the format "service:ActionName" where:
35
+ - service is the AWS service prefix (case-insensitive, typically lowercase)
36
+ - ActionName is the specific API action (PascalCase or camelCase)
37
+
38
+ Args:
39
+ action: The action string to parse (e.g., "s3:GetObject", "ec2:*")
40
+
41
+ Returns:
42
+ ParsedAction if the action is valid, None if malformed.
43
+
44
+ Examples:
45
+ >>> parse_action("s3:GetObject")
46
+ ParsedAction(service="s3", action_name="GetObject", has_wildcard=False, original="s3:GetObject")
47
+
48
+ >>> parse_action("ec2:Describe*")
49
+ ParsedAction(service="ec2", action_name="Describe*", has_wildcard=True, original="ec2:Describe*")
50
+
51
+ >>> parse_action("InvalidAction")
52
+ None
53
+
54
+ >>> parse_action("*")
55
+ None
56
+ """
57
+ # Handle full wildcard - not a parseable service:action
58
+ if action == "*":
59
+ return None
60
+
61
+ # Must contain exactly one colon separating service and action
62
+ if ":" not in action:
63
+ return None
64
+
65
+ # Split on first colon only (action names can theoretically contain colons)
66
+ parts = action.split(":", 1)
67
+ if len(parts) != 2:
68
+ return None
69
+
70
+ service, action_name = parts
71
+
72
+ # Both service and action name must be non-empty
73
+ if not service or not action_name:
74
+ return None
75
+
76
+ return ParsedAction(
77
+ service=service,
78
+ action_name=action_name,
79
+ has_wildcard="*" in service or "*" in action_name,
80
+ original=action,
81
+ )
82
+
83
+
84
+ def is_wildcard_action(action: str) -> bool:
85
+ """Check if an action contains a wildcard.
86
+
87
+ Args:
88
+ action: The action string to check
89
+
90
+ Returns:
91
+ True if the action is "*" or contains "*" in service or action name
92
+ """
93
+ if action == "*":
94
+ return True
95
+
96
+ parsed = parse_action(action)
97
+ return parsed.has_wildcard if parsed else False
98
+
99
+
100
+ def extract_service(action: str) -> str | None:
101
+ """Extract the service prefix from an action string.
102
+
103
+ Args:
104
+ action: The action string (e.g., "s3:GetObject")
105
+
106
+ Returns:
107
+ The service prefix (e.g., "s3") or None if the action is malformed
108
+ """
109
+ if action == "*":
110
+ return None
111
+
112
+ parsed = parse_action(action)
113
+ return parsed.service if parsed else None
114
+
115
+
116
+ def get_action_case_insensitive(actions_dict: dict[str, T], action_name: str) -> T | None:
117
+ """Look up an action in a dictionary using case-insensitive matching.
118
+
119
+ AWS action names are case-insensitive, but our service definitions may have
120
+ canonical casing. This function tries exact match first, then falls back
121
+ to case-insensitive lookup.
122
+
123
+ Args:
124
+ actions_dict: Dictionary mapping action names to values (e.g., ActionDetail)
125
+ action_name: The action name to look up
126
+
127
+ Returns:
128
+ The value if found, None otherwise
129
+
130
+ Examples:
131
+ >>> actions = {"GetObject": detail, "PutObject": detail2}
132
+ >>> get_action_case_insensitive(actions, "GetObject") # Exact match
133
+ detail
134
+ >>> get_action_case_insensitive(actions, "getobject") # Case-insensitive
135
+ detail
136
+ >>> get_action_case_insensitive(actions, "Unknown")
137
+ None
138
+ """
139
+ # Try exact match first (most common case)
140
+ if action_name in actions_dict:
141
+ return actions_dict[action_name]
142
+
143
+ # Fall back to case-insensitive lookup
144
+ action_name_lower = action_name.lower()
145
+ for key, value in actions_dict.items():
146
+ if key.lower() == action_name_lower:
147
+ return value
148
+
149
+ return None
@@ -51,6 +51,7 @@ class WildcardActionCheck(PolicyCheck):
51
51
  suggestion=suggestion,
52
52
  example=example if example else None,
53
53
  line_number=statement.line_number,
54
+ field_name="action",
54
55
  )
55
56
  )
56
57
 
@@ -1,11 +1,73 @@
1
1
  """Wildcard resource check - detects Resource: '*' in IAM policies."""
2
2
 
3
+ import asyncio
4
+ import logging
3
5
  from typing import ClassVar
4
6
 
7
+ from iam_validator.checks.utils.action_parser import get_action_case_insensitive, parse_action
5
8
  from iam_validator.checks.utils.wildcard_expansion import expand_wildcard_actions
6
9
  from iam_validator.core.aws_service import AWSServiceFetcher
7
10
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
8
- from iam_validator.core.models import Statement, ValidationIssue
11
+ from iam_validator.core.models import ActionDetail, ServiceDetail, Statement, ValidationIssue
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Module-level cache for action resource support lookups.
16
+ # Maps action name (e.g., "s3:GetObject") to whether it supports resource-level permissions.
17
+ # True = supports resources (should be flagged for wildcard)
18
+ # False = doesn't support resources (wildcard is appropriate)
19
+ # None = unknown (be conservative, assume it supports resources)
20
+ _action_resource_support_cache: dict[str, bool | None] = {}
21
+
22
+ # Module-level cache for action access level lookups.
23
+ # Maps action name (e.g., "s3:ListBuckets") to its access level.
24
+ # "list" = list-level action (safe with wildcards)
25
+ # Other values or None = unknown
26
+ _action_access_level_cache: dict[str, str | None] = {}
27
+
28
+
29
+ def _get_access_level(action_detail: ActionDetail) -> str:
30
+ """Derive access level from action annotations.
31
+
32
+ AWS API provides Properties dict with boolean flags instead of AccessLevel string.
33
+ We derive the access level from these flags.
34
+
35
+ Args:
36
+ action_detail: Action detail object with annotations
37
+
38
+ Returns:
39
+ Access level string: "permissions-management", "tagging", "write", "list", or "read"
40
+ """
41
+ if not action_detail.annotations:
42
+ return "unknown"
43
+
44
+ props = action_detail.annotations.get("Properties", {})
45
+ if not props:
46
+ return "unknown"
47
+
48
+ # Check flags in priority order
49
+ if props.get("IsPermissionManagement"):
50
+ return "permissions-management"
51
+ if props.get("IsTaggingOnly"):
52
+ return "tagging"
53
+ if props.get("IsWrite"):
54
+ return "write"
55
+ if props.get("IsList"):
56
+ return "list"
57
+
58
+ # Default to read if none of the above
59
+ return "read"
60
+
61
+
62
+ def clear_resource_support_cache() -> None:
63
+ """Clear the action resource support and access level caches.
64
+
65
+ This is primarily useful for testing to ensure a clean state between tests.
66
+ In production, the cache persists for the lifetime of the process, which is
67
+ beneficial as AWS action definitions don't change frequently.
68
+ """
69
+ _action_resource_support_cache.clear()
70
+ _action_access_level_cache.clear()
9
71
 
10
72
 
11
73
  class WildcardResourceCheck(PolicyCheck):
@@ -34,6 +96,18 @@ class WildcardResourceCheck(PolicyCheck):
34
96
 
35
97
  # Check for wildcard resource (Resource: "*")
36
98
  if "*" in resources:
99
+ # First, filter out actions that don't support resource-level permissions
100
+ # These actions legitimately require Resource: "*"
101
+ actions_requiring_specific_resources = await self._filter_actions_requiring_resources(
102
+ actions, fetcher
103
+ )
104
+
105
+ # If all actions don't support resources, wildcard is appropriate - no issue
106
+ if not actions_requiring_specific_resources:
107
+ return issues
108
+
109
+ # Use filtered actions for the rest of the check
110
+ actions = actions_requiring_specific_resources
37
111
  # Check if all actions are in the allowed_wildcards list
38
112
  # allowed_wildcards works by expanding wildcard patterns (like "ec2:Describe*")
39
113
  # to all matching AWS actions using the AWS API, then checking if the policy's
@@ -78,9 +152,25 @@ class WildcardResourceCheck(PolicyCheck):
78
152
  return issues
79
153
 
80
154
  # Flag the issue if actions are not all allowed or no allowed_wildcards configured
81
- message = config.config.get(
82
- "message", 'Statement applies to all resources `"*"` (wildcard resource).'
83
- )
155
+ # Build a helpful message showing which actions require specific resources
156
+ custom_message = config.config.get("message")
157
+ if custom_message:
158
+ message = custom_message
159
+ else:
160
+ # Build default message with action list
161
+ # Note: actions_requiring_specific_resources is guaranteed non-empty here
162
+ # because we return early above if it's empty
163
+ sorted_actions = sorted(actions_requiring_specific_resources)
164
+ if len(sorted_actions) <= 5:
165
+ action_list = ", ".join(f"`{a}`" for a in sorted_actions)
166
+ else:
167
+ action_list = ", ".join(f"`{a}`" for a in sorted_actions[:5])
168
+ action_list += f" (+{len(sorted_actions) - 5} more)"
169
+ message = (
170
+ f'Statement applies to all resources `"*"`. '
171
+ f"Actions that support resource-level permissions: {action_list}"
172
+ )
173
+
84
174
  suggestion = config.config.get(
85
175
  "suggestion", "Replace wildcard with specific resource ARNs"
86
176
  )
@@ -96,6 +186,7 @@ class WildcardResourceCheck(PolicyCheck):
96
186
  suggestion=suggestion,
97
187
  example=example if example else None,
98
188
  line_number=statement.line_number,
189
+ field_name="resource",
99
190
  )
100
191
  )
101
192
 
@@ -145,3 +236,139 @@ class WildcardResourceCheck(PolicyCheck):
145
236
  expanded_actions = await expand_wildcard_actions(patterns_to_expand, fetcher)
146
237
 
147
238
  return frozenset(expanded_actions)
239
+
240
+ async def _filter_actions_requiring_resources(
241
+ self, actions: list[str], fetcher: AWSServiceFetcher
242
+ ) -> list[str]:
243
+ """Filter actions to only those that should be flagged for wildcard resources.
244
+
245
+ This method filters out actions that legitimately use Resource: "*":
246
+ 1. Actions that don't support resource-level permissions (e.g., sts:GetCallerIdentity)
247
+ 2. List-level actions (e.g., s3:ListBuckets) - these only enumerate resources
248
+ and are not dangerous with wildcards
249
+
250
+ Examples of actions filtered out:
251
+ - iam:ListUsers (list-level, must use Resource: "*")
252
+ - sts:GetCallerIdentity (must use Resource: "*")
253
+ - ec2:DescribeInstances (must use Resource: "*")
254
+ - s3:ListAllMyBuckets (list-level)
255
+
256
+ This method uses a module-level cache to avoid repeated lookups and
257
+ fetches all required services in parallel for better performance.
258
+
259
+ Args:
260
+ actions: List of actions from the policy statement
261
+ fetcher: AWS service fetcher for looking up action definitions
262
+
263
+ Returns:
264
+ List of actions that should be flagged for wildcard resource usage
265
+ """
266
+ actions_requiring_resources = []
267
+ # Actions that need service lookup, grouped by service
268
+ service_actions: dict[str, list[tuple[str, str]]] = {} # service -> [(action, action_name)]
269
+
270
+ for action in actions:
271
+ # Full wildcard "*" - keep it (it's too broad to determine)
272
+ if action == "*":
273
+ actions_requiring_resources.append(action)
274
+ continue
275
+
276
+ # Parse action using the utility
277
+ parsed = parse_action(action)
278
+ if not parsed:
279
+ # Malformed action - keep it (be conservative)
280
+ actions_requiring_resources.append(action)
281
+ continue
282
+
283
+ # Wildcard in service or action name - keep it (can't determine resource support)
284
+ if parsed.has_wildcard:
285
+ actions_requiring_resources.append(action)
286
+ continue
287
+
288
+ service = parsed.service
289
+ action_name = parsed.action_name
290
+
291
+ # Check module-level caches first
292
+ if action in _action_resource_support_cache and action in _action_access_level_cache:
293
+ cached_resource_support = _action_resource_support_cache[action]
294
+ cached_access_level = _action_access_level_cache[action]
295
+
296
+ # Skip list-level actions - they're safe with wildcards
297
+ if cached_access_level == "list":
298
+ continue
299
+
300
+ if cached_resource_support is True or cached_resource_support is None:
301
+ # Supports resources or unknown - include it
302
+ actions_requiring_resources.append(action)
303
+ # If False, action doesn't support resources - skip it
304
+ continue
305
+
306
+ # Group actions by service for parallel fetching
307
+ if service not in service_actions:
308
+ service_actions[service] = []
309
+ service_actions[service].append((action, action_name))
310
+
311
+ # If no services to look up, return early
312
+ if not service_actions:
313
+ return actions_requiring_resources
314
+
315
+ # Fetch all services in parallel
316
+ services = list(service_actions.keys())
317
+ results = await asyncio.gather(
318
+ *[fetcher.fetch_service_by_name(s) for s in services],
319
+ return_exceptions=True,
320
+ )
321
+
322
+ # Build service cache from successful results
323
+ service_cache: dict[str, ServiceDetail | None] = {}
324
+ for service, result in zip(services, results):
325
+ if isinstance(result, BaseException):
326
+ logger.debug(f"Could not look up service {service}: {result}")
327
+ # Mark service as failed - will keep all its actions (conservative)
328
+ service_cache[service] = None
329
+ else:
330
+ # Result is ServiceDetail when not an exception
331
+ service_cache[service] = result
332
+
333
+ # Process actions using cached service data
334
+ for service, action_list in service_actions.items():
335
+ service_detail = service_cache.get(service)
336
+
337
+ if not service_detail:
338
+ # Unknown service - keep all its actions (be conservative)
339
+ for action, _ in action_list:
340
+ _action_resource_support_cache[action] = None # Cache as unknown
341
+ _action_access_level_cache[action] = None # Cache as unknown
342
+ actions_requiring_resources.append(action)
343
+ continue
344
+
345
+ for action, action_name in action_list:
346
+ # Use case-insensitive lookup since AWS actions are case-insensitive
347
+ action_detail = get_action_case_insensitive(service_detail.actions, action_name)
348
+ if not action_detail:
349
+ # Unknown action - keep it (be conservative)
350
+ _action_resource_support_cache[action] = None # Cache as unknown
351
+ _action_access_level_cache[action] = None # Cache as unknown
352
+ actions_requiring_resources.append(action)
353
+ continue
354
+
355
+ # Get action's access level and cache it
356
+ access_level = _get_access_level(action_detail)
357
+ _action_access_level_cache[action] = access_level
358
+
359
+ # Skip list-level actions - they only enumerate resources and are safe with wildcards
360
+ if access_level == "list":
361
+ _action_resource_support_cache[action] = False # Mark as not needing resources
362
+ continue
363
+
364
+ # Check if action supports resource-level permissions
365
+ # action_detail.resources is empty for actions that don't support resources
366
+ supports_resources = bool(action_detail.resources)
367
+ _action_resource_support_cache[action] = supports_resources # Cache result
368
+
369
+ if supports_resources:
370
+ # Action supports resources - should be flagged for wildcard
371
+ actions_requiring_resources.append(action)
372
+ # Else: action doesn't support resources, Resource: "*" is appropriate
373
+
374
+ return actions_requiring_resources
@@ -387,10 +387,28 @@ Examples:
387
387
 
388
388
  # Post to GitHub if configured
389
389
  if args.github_comment:
390
+ from iam_validator.core.config.config_loader import ConfigLoader
390
391
  from iam_validator.core.pr_commenter import PRCommenter
391
392
 
393
+ # Load config to get fail_on_severity, severity_labels, and ignore settings
394
+ config_path = getattr(args, "config", None)
395
+ config = ConfigLoader.load_config(config_path)
396
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
397
+ severity_labels = config.get_setting("severity_labels", {})
398
+
399
+ # Get ignore settings from config
400
+ ignore_settings = config.get_setting("ignore_settings", {})
401
+ enable_ignore = ignore_settings.get("enabled", True)
402
+ allowed_users = ignore_settings.get("allowed_users", [])
403
+
392
404
  async with GitHubIntegration() as github:
393
- commenter = PRCommenter(github)
405
+ commenter = PRCommenter(
406
+ github,
407
+ fail_on_severities=fail_on_severities,
408
+ severity_labels=severity_labels,
409
+ enable_codeowners_ignore=enable_ignore,
410
+ allowed_ignore_users=allowed_users,
411
+ )
394
412
  success = await commenter.post_findings_to_pr(
395
413
  validation_report,
396
414
  create_review=getattr(args, "github_review", False),
@@ -247,7 +247,7 @@ _iam_validator_completion() {{
247
247
  return 0
248
248
  ;;
249
249
  validate)
250
- opts="--path -p --stdin --format -f --output -o --no-recursive --fail-on-warnings --policy-type -t --github-comment --github-review --github-summary --verbose -v --config -c --custom-checks-dir --aws-services-dir --stream --batch-size --summary --severity-breakdown"
250
+ opts="--path -p --stdin --format -f --output -o --no-recursive --fail-on-warnings --policy-type -t --github-comment --github-review --github-summary --verbose -v --config -c --custom-checks-dir --aws-services-dir --stream --batch-size --summary --severity-breakdown --allow-owner-ignore --no-owner-ignore --ci --ci-output"
251
251
  COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
252
252
  return 0
253
253
  ;;
@@ -376,7 +376,11 @@ _iam_validator() {{
376
376
  '--stream[Process files one-by-one]' \\
377
377
  '--batch-size[Policies per batch]:number:' \\
378
378
  '--summary[Show Executive Summary section]' \\
379
- '--severity-breakdown[Show Issue Severity Breakdown section]'
379
+ '--severity-breakdown[Show Issue Severity Breakdown section]' \\
380
+ '--allow-owner-ignore[Allow CODEOWNERS to ignore findings]' \\
381
+ '--no-owner-ignore[Disable CODEOWNERS ignore feature]' \\
382
+ '--ci[CI mode - print enhanced output, write JSON to file]' \\
383
+ '--ci-output[Output file for JSON report in CI mode]:file:_files'
380
384
  ;;
381
385
  post-to-pr)
382
386
  _arguments \\