iam-policy-validator 1.8.0__py3-none-any.whl → 1.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {iam_policy_validator-1.8.0.dist-info → iam_policy_validator-1.9.0.dist-info}/METADATA +106 -1
  2. {iam_policy_validator-1.8.0.dist-info → iam_policy_validator-1.9.0.dist-info}/RECORD +45 -37
  3. iam_validator/__init__.py +1 -1
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/action_condition_enforcement.py +504 -190
  6. iam_validator/checks/action_resource_matching.py +8 -15
  7. iam_validator/checks/action_validation.py +6 -12
  8. iam_validator/checks/condition_key_validation.py +6 -12
  9. iam_validator/checks/condition_type_mismatch.py +9 -16
  10. iam_validator/checks/full_wildcard.py +9 -13
  11. iam_validator/checks/mfa_condition_check.py +8 -17
  12. iam_validator/checks/policy_size.py +6 -39
  13. iam_validator/checks/policy_structure.py +10 -40
  14. iam_validator/checks/policy_type_validation.py +18 -19
  15. iam_validator/checks/principal_validation.py +11 -20
  16. iam_validator/checks/resource_validation.py +5 -12
  17. iam_validator/checks/sensitive_action.py +8 -15
  18. iam_validator/checks/service_wildcard.py +6 -12
  19. iam_validator/checks/set_operator_validation.py +11 -18
  20. iam_validator/checks/sid_uniqueness.py +8 -38
  21. iam_validator/checks/trust_policy_validation.py +8 -14
  22. iam_validator/checks/utils/wildcard_expansion.py +1 -1
  23. iam_validator/checks/wildcard_action.py +6 -12
  24. iam_validator/checks/wildcard_resource.py +6 -12
  25. iam_validator/commands/cache.py +4 -3
  26. iam_validator/commands/validate.py +12 -0
  27. iam_validator/core/__init__.py +1 -1
  28. iam_validator/core/aws_fetcher.py +24 -1030
  29. iam_validator/core/aws_service/__init__.py +21 -0
  30. iam_validator/core/aws_service/cache.py +108 -0
  31. iam_validator/core/aws_service/client.py +205 -0
  32. iam_validator/core/aws_service/fetcher.py +612 -0
  33. iam_validator/core/aws_service/parsers.py +149 -0
  34. iam_validator/core/aws_service/patterns.py +51 -0
  35. iam_validator/core/aws_service/storage.py +291 -0
  36. iam_validator/core/aws_service/validators.py +379 -0
  37. iam_validator/core/check_registry.py +82 -14
  38. iam_validator/core/constants.py +17 -0
  39. iam_validator/core/policy_checks.py +7 -3
  40. iam_validator/sdk/__init__.py +1 -1
  41. iam_validator/sdk/context.py +1 -1
  42. iam_validator/sdk/helpers.py +1 -1
  43. {iam_policy_validator-1.8.0.dist-info → iam_policy_validator-1.9.0.dist-info}/WHEEL +0 -0
  44. {iam_policy_validator-1.8.0.dist-info → iam_policy_validator-1.9.0.dist-info}/entry_points.txt +0 -0
  45. {iam_policy_validator-1.8.0.dist-info → iam_policy_validator-1.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,149 @@
1
+ """Parsing and pattern matching for AWS actions and resources.
2
+
3
+ This module provides functionality to parse IAM actions, validate ARN formats,
4
+ and perform wildcard matching on action patterns.
5
+ """
6
+
7
+ import re
8
+
9
+ from iam_validator.core.aws_service.patterns import CompiledPatterns
10
+
11
+
12
+ class ServiceParser:
13
+ """Parses and matches AWS actions, ARNs, and wildcards.
14
+
15
+ This class provides methods for:
16
+ - Parsing IAM actions into service prefix and action name
17
+ - Validating ARN format
18
+ - Detecting and matching wildcard patterns
19
+ - Expanding wildcard actions to full action lists
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ """Initialize parser with compiled patterns."""
24
+ self._patterns = CompiledPatterns()
25
+
26
+ def parse_action(self, action: str) -> tuple[str, str]:
27
+ """Parse IAM action into service prefix and action name.
28
+
29
+ Args:
30
+ action: Full action string (e.g., "s3:GetObject", "iam:CreateUser")
31
+
32
+ Returns:
33
+ Tuple of (service_prefix, action_name) both lowercase
34
+
35
+ Raises:
36
+ ValueError: If action format is invalid
37
+
38
+ Example:
39
+ >>> parser = ServiceParser()
40
+ >>> parser.parse_action("s3:GetObject")
41
+ ('s3', 'GetObject')
42
+ """
43
+ match = self._patterns.action_pattern.match(action)
44
+ if not match:
45
+ raise ValueError(f"Invalid action format: {action}")
46
+
47
+ return match.group("service").lower(), match.group("action")
48
+
49
+ def validate_arn_format(self, arn: str) -> tuple[bool, str | None]:
50
+ """Validate ARN format using compiled regex.
51
+
52
+ Args:
53
+ arn: ARN string to validate
54
+
55
+ Returns:
56
+ Tuple of (is_valid, error_message). error_message is None if valid.
57
+
58
+ Example:
59
+ >>> parser = ServiceParser()
60
+ >>> parser.validate_arn_format("arn:aws:s3:::my-bucket/*")
61
+ (True, None)
62
+ >>> parser.validate_arn_format("invalid")
63
+ (False, "Invalid ARN format: invalid")
64
+ """
65
+ if arn == "*":
66
+ return True, None
67
+
68
+ match = self._patterns.arn_pattern.match(arn)
69
+ if not match:
70
+ return False, f"Invalid ARN format: {arn}"
71
+
72
+ return True, None
73
+
74
+ def is_wildcard_action(self, action_name: str) -> bool:
75
+ """Check if action name contains wildcards.
76
+
77
+ Args:
78
+ action_name: Action name to check (e.g., "GetObject", "Get*", "*")
79
+
80
+ Returns:
81
+ True if action contains wildcard characters
82
+
83
+ Example:
84
+ >>> parser = ServiceParser()
85
+ >>> parser.is_wildcard_action("GetObject")
86
+ False
87
+ >>> parser.is_wildcard_action("Get*")
88
+ True
89
+ """
90
+ return bool(self._patterns.wildcard_pattern.search(action_name))
91
+
92
+ def match_wildcard_action(self, pattern: str, actions: list[str]) -> tuple[bool, list[str]]:
93
+ """Match wildcard pattern against list of actions.
94
+
95
+ Args:
96
+ pattern: Action pattern with wildcards (e.g., "Get*", "*Object", "Describe*")
97
+ actions: List of valid action names to match against
98
+
99
+ Returns:
100
+ Tuple of (has_matches, list_of_matched_actions)
101
+
102
+ Example:
103
+ >>> parser = ServiceParser()
104
+ >>> actions = ["GetObject", "GetBucket", "PutObject"]
105
+ >>> parser.match_wildcard_action("Get*", actions)
106
+ (True, ['GetObject', 'GetBucket'])
107
+ """
108
+ # Convert wildcard pattern to regex
109
+ # Escape special regex chars except *, then replace * with .*
110
+ regex_pattern = "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
111
+ compiled_pattern = re.compile(regex_pattern, re.IGNORECASE)
112
+
113
+ matched = [a for a in actions if compiled_pattern.match(a)]
114
+ return len(matched) > 0, matched
115
+
116
+ def expand_wildcard_to_actions(
117
+ self,
118
+ action_pattern: str,
119
+ available_actions: list[str],
120
+ service_prefix: str,
121
+ ) -> list[str]:
122
+ """Expand wildcard pattern to full list of actions.
123
+
124
+ Args:
125
+ action_pattern: Action pattern (e.g., "s3:Get*", "iam:*")
126
+ available_actions: List of available action names for the service
127
+ service_prefix: Service prefix (e.g., "s3", "iam")
128
+
129
+ Returns:
130
+ Sorted list of fully-qualified actions matching the pattern
131
+
132
+ Example:
133
+ >>> parser = ServiceParser()
134
+ >>> actions = ["GetObject", "PutObject", "DeleteObject"]
135
+ >>> parser.expand_wildcard_to_actions("s3:*Object", actions, "s3")
136
+ ['s3:DeleteObject', 's3:GetObject', 's3:PutObject']
137
+ """
138
+ # Parse to get action name part
139
+ _, action_name = self.parse_action(action_pattern)
140
+
141
+ # Handle full service wildcard (e.g., "iam:*")
142
+ if action_name == "*":
143
+ return sorted([f"{service_prefix}:{action}" for action in available_actions])
144
+
145
+ # Match wildcard pattern
146
+ _, matched_actions = self.match_wildcard_action(action_name, available_actions)
147
+
148
+ # Return fully-qualified actions
149
+ return sorted([f"{service_prefix}:{action}" for action in matched_actions])
@@ -0,0 +1,51 @@
1
+ """Pre-compiled regex patterns for AWS service validation.
2
+
3
+ This module provides a singleton class containing pre-compiled regex patterns
4
+ used across the AWS service validation system for better performance.
5
+ """
6
+
7
+ import re
8
+
9
+
10
+ class CompiledPatterns:
11
+ """Pre-compiled regex patterns for validation.
12
+
13
+ This class implements the Singleton pattern to ensure patterns are compiled only once
14
+ and reused across all instances for better performance.
15
+ """
16
+
17
+ _instance: "CompiledPatterns | None" = None
18
+ _initialized: bool = False
19
+
20
+ def __new__(cls) -> "CompiledPatterns":
21
+ """Create or return the singleton instance."""
22
+ if cls._instance is None:
23
+ cls._instance = super().__new__(cls)
24
+ return cls._instance
25
+
26
+ def __init__(self) -> None:
27
+ """Initialize compiled patterns (only once due to Singleton pattern)."""
28
+ # Only initialize once, even if __init__ is called multiple times
29
+ if CompiledPatterns._initialized:
30
+ return
31
+
32
+ CompiledPatterns._initialized = True
33
+
34
+ # ARN validation pattern
35
+ self.arn_pattern = re.compile(
36
+ r"^arn:(?P<partition>(aws|aws-cn|aws-us-gov|aws-eusc|aws-iso|aws-iso-b|aws-iso-e|aws-iso-f)):"
37
+ r"(?P<service>[a-z0-9\-]+):"
38
+ r"(?P<region>[a-z0-9\-]*):"
39
+ r"(?P<account>[0-9]*):"
40
+ r"(?P<resource>.+)$",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ # Action format pattern
45
+ self.action_pattern = re.compile(
46
+ r"^(?P<service>[a-zA-Z0-9_-]+):(?P<action>[a-zA-Z0-9*_-]+)$"
47
+ )
48
+
49
+ # Wildcard detection patterns
50
+ self.wildcard_pattern = re.compile(r"\*")
51
+ self.partial_wildcard_pattern = re.compile(r"^[^*]+\*$")
@@ -0,0 +1,291 @@
1
+ """File storage operations for AWS service data.
2
+
3
+ This module handles disk caching and offline file loading for AWS service definitions.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from iam_validator.core.models import ServiceDetail, ServiceInfo
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ServiceFileStorage:
21
+ """Handles disk cache and offline AWS service file operations.
22
+
23
+ This class manages:
24
+ - Disk-based caching with TTL
25
+ - Loading AWS service definitions from local files
26
+ - Platform-specific cache directory management
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ cache_dir: Path | str | None = None,
32
+ aws_services_dir: Path | str | None = None,
33
+ cache_ttl: int = 86400,
34
+ enable_cache: bool = True,
35
+ ) -> None:
36
+ """Initialize storage with cache and offline directories.
37
+
38
+ Args:
39
+ cache_dir: Custom cache directory path (uses platform default if None)
40
+ aws_services_dir: Directory containing pre-downloaded AWS service JSON files.
41
+ When set, enables offline mode. Directory should contain:
42
+ - _services.json: List of all services
43
+ - {service}.json: Individual service files (e.g., s3.json)
44
+ cache_ttl: Cache time-to-live in seconds
45
+ enable_cache: Enable persistent disk caching
46
+ """
47
+ self.cache_ttl = cache_ttl
48
+ self.enable_cache = enable_cache
49
+ self._cache_dir = self.get_cache_directory(cache_dir)
50
+
51
+ # AWS services directory for offline mode
52
+ self.aws_services_dir: Path | None = None
53
+ if aws_services_dir:
54
+ self.aws_services_dir = Path(aws_services_dir)
55
+ if not self.aws_services_dir.exists():
56
+ raise ValueError(f"AWS services directory does not exist: {aws_services_dir}")
57
+ logger.info(f"Using local AWS services from: {self.aws_services_dir}")
58
+
59
+ # Create cache directory if needed
60
+ if self.enable_cache:
61
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ @staticmethod
64
+ def get_cache_directory(cache_dir: Path | str | None = None) -> Path:
65
+ """Get the cache directory path, using platform-appropriate defaults.
66
+
67
+ Priority:
68
+ 1. Provided cache_dir parameter
69
+ 2. Platform-specific user cache directory
70
+ - Linux/Unix: ~/.cache/iam-validator/aws_services
71
+ - macOS: ~/Library/Caches/iam-validator/aws_services
72
+ - Windows: %LOCALAPPDATA%/iam-validator/cache/aws_services
73
+
74
+ Args:
75
+ cache_dir: Optional custom cache directory path
76
+
77
+ Returns:
78
+ Path object for the cache directory
79
+ """
80
+ if cache_dir is not None:
81
+ return Path(cache_dir)
82
+
83
+ # Determine platform-specific cache directory
84
+ if sys.platform == "darwin":
85
+ # macOS
86
+ base_cache = Path.home() / "Library" / "Caches"
87
+ elif sys.platform == "win32":
88
+ # Windows
89
+ base_cache = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
90
+ else:
91
+ # Linux and other Unix-like systems
92
+ base_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
93
+
94
+ return base_cache / "iam-validator" / "aws_services"
95
+
96
+ def set_cache_directory(self, cache_dir: Path | str) -> None:
97
+ """Set a new cache directory path dynamically.
98
+
99
+ This method allows library users to change the cache location at runtime.
100
+ The new directory will be created if it doesn't exist and caching is enabled.
101
+
102
+ Args:
103
+ cache_dir: New cache directory path
104
+
105
+ Example:
106
+ >>> storage = ServiceFileStorage()
107
+ >>> storage.set_cache_directory("/tmp/my-custom-cache")
108
+ >>> # Future cache operations will use the new directory
109
+ """
110
+ self._cache_dir = Path(cache_dir)
111
+
112
+ # Create new cache directory if caching is enabled
113
+ if self.enable_cache:
114
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
115
+ logger.info(f"Cache directory updated to: {self._cache_dir}")
116
+
117
+ @property
118
+ def cache_directory(self) -> Path:
119
+ """Get the current cache directory path.
120
+
121
+ Returns:
122
+ Current cache directory as Path object
123
+
124
+ Example:
125
+ >>> storage = ServiceFileStorage()
126
+ >>> print(storage.cache_directory)
127
+ PosixPath('/Users/username/Library/Caches/iam-validator/aws_services')
128
+ """
129
+ return self._cache_dir
130
+
131
+ def _get_cache_path(self, url: str, base_url: str) -> Path:
132
+ """Generate cache file path for URL.
133
+
134
+ Args:
135
+ url: URL to generate cache path for
136
+ base_url: Base URL for service reference API
137
+
138
+ Returns:
139
+ Path to cache file
140
+ """
141
+ url_hash = hashlib.md5(url.encode()).hexdigest()
142
+
143
+ # Extract service name for better organization
144
+ filename = f"{url_hash}.json"
145
+ if "/v1/" in url:
146
+ service_name = url.split("/v1/")[1].split("/")[0]
147
+ filename = f"{service_name}_{url_hash[:8]}.json"
148
+ elif url == base_url:
149
+ filename = "services_list.json"
150
+
151
+ return self._cache_dir / filename
152
+
153
+ def read_from_cache(self, url: str, base_url: str) -> Any | None:
154
+ """Read from disk cache with TTL checking.
155
+
156
+ Args:
157
+ url: URL to read from cache
158
+ base_url: Base URL for service reference API
159
+
160
+ Returns:
161
+ Cached data if valid, None otherwise
162
+ """
163
+ if not self.enable_cache:
164
+ return None
165
+
166
+ cache_path = self._get_cache_path(url, base_url)
167
+
168
+ if not cache_path.exists():
169
+ return None
170
+
171
+ try:
172
+ # Check file modification time for TTL
173
+ mtime = cache_path.stat().st_mtime
174
+ if time.time() - mtime > self.cache_ttl:
175
+ logger.debug(f"Cache expired for {url}")
176
+ cache_path.unlink() # Remove expired cache
177
+ return None
178
+
179
+ with open(cache_path, encoding="utf-8") as f:
180
+ data = json.load(f)
181
+ logger.debug(f"Disk cache hit for {url}")
182
+ return data
183
+
184
+ except Exception as e: # pylint: disable=broad-exception-caught
185
+ logger.warning(f"Failed to read cache for {url}: {e}")
186
+ return None
187
+
188
+ def write_to_cache(self, url: str, data: Any, base_url: str) -> None:
189
+ """Write to disk cache.
190
+
191
+ Args:
192
+ url: URL to cache data for
193
+ data: Data to cache
194
+ base_url: Base URL for service reference API
195
+ """
196
+ if not self.enable_cache:
197
+ return
198
+
199
+ cache_path = self._get_cache_path(url, base_url)
200
+
201
+ try:
202
+ with open(cache_path, "w", encoding="utf-8") as f:
203
+ json.dump(data, f, indent=2)
204
+ logger.debug(f"Written to disk cache: {url}")
205
+ except Exception as e: # pylint: disable=broad-exception-caught
206
+ logger.warning(f"Failed to write cache for {url}: {e}")
207
+
208
+ def load_services_from_file(self) -> list[ServiceInfo]:
209
+ """Load services list from local _services.json file.
210
+
211
+ Returns:
212
+ List of ServiceInfo objects loaded from _services.json
213
+
214
+ Raises:
215
+ ValueError: If aws_services_dir is not set or _services.json is invalid
216
+ FileNotFoundError: If _services.json doesn't exist
217
+ """
218
+ if not self.aws_services_dir:
219
+ raise ValueError("aws_services_dir is not set")
220
+
221
+ services_file = self.aws_services_dir / "_services.json"
222
+ if not services_file.exists():
223
+ raise FileNotFoundError(f"_services.json not found in {self.aws_services_dir}")
224
+
225
+ try:
226
+ with open(services_file, encoding="utf-8") as f:
227
+ data = json.load(f)
228
+
229
+ if not isinstance(data, list):
230
+ raise ValueError("Expected list of services from _services.json")
231
+
232
+ services: list[ServiceInfo] = []
233
+ for item in data:
234
+ if isinstance(item, dict):
235
+ service = item.get("service")
236
+ url = item.get("url")
237
+ if service and url:
238
+ services.append(ServiceInfo(service=str(service), url=str(url)))
239
+
240
+ logger.info(f"Loaded {len(services)} services from local file: {services_file}")
241
+ return services
242
+
243
+ except json.JSONDecodeError as e:
244
+ raise ValueError(f"Invalid JSON in _services.json: {e}") from e
245
+
246
+ def load_service_from_file(self, service_name: str) -> ServiceDetail:
247
+ """Load service detail from local JSON file.
248
+
249
+ Args:
250
+ service_name: Name of the service (case-insensitive)
251
+
252
+ Returns:
253
+ ServiceDetail object loaded from {service}.json
254
+
255
+ Raises:
256
+ ValueError: If aws_services_dir is not set or service JSON is invalid
257
+ FileNotFoundError: If service JSON file doesn't exist
258
+ """
259
+ if not self.aws_services_dir:
260
+ raise ValueError("aws_services_dir is not set")
261
+
262
+ # Normalize filename (lowercase, replace spaces with underscores)
263
+ filename = f"{service_name.lower().replace(' ', '_')}.json"
264
+ service_file = self.aws_services_dir / filename
265
+
266
+ if not service_file.exists():
267
+ raise FileNotFoundError(f"Service file not found: {service_file}")
268
+
269
+ try:
270
+ with open(service_file, encoding="utf-8") as f:
271
+ data = json.load(f)
272
+
273
+ service_detail = ServiceDetail.model_validate(data)
274
+ logger.debug(f"Loaded service {service_name} from local file: {service_file}")
275
+ return service_detail
276
+
277
+ except json.JSONDecodeError as e:
278
+ raise ValueError(f"Invalid JSON in {service_file}: {e}") from e
279
+
280
+ def clear_disk_cache(self) -> None:
281
+ """Remove all cached files from disk."""
282
+ if not self.enable_cache or not self._cache_dir.exists():
283
+ return
284
+
285
+ for cache_file in self._cache_dir.glob("*.json"):
286
+ try:
287
+ cache_file.unlink()
288
+ except Exception as e: # pylint: disable=broad-exception-caught
289
+ logger.warning(f"Failed to delete cache file {cache_file}: {e}")
290
+
291
+ logger.info("Cleared disk cache")