iam-policy-validator 1.7.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.

Potentially problematic release.


This version of iam-policy-validator might be problematic. Click here for more details.

Files changed (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. iam_validator/utils/regex.py +206 -0
@@ -0,0 +1,472 @@
1
+ """
2
+ Configuration loader for IAM Policy Validator.
3
+
4
+ Loads and parses configuration from YAML files, environment variables,
5
+ and command-line arguments.
6
+ """
7
+
8
+ import importlib.util
9
+ import inspect
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from iam_validator.core.check_registry import CheckConfig, CheckRegistry, PolicyCheck
18
+ from iam_validator.core.config.defaults import get_default_config
19
+ from iam_validator.core.constants import DEFAULT_CONFIG_FILENAMES
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def deep_merge(base: dict, override: dict) -> dict:
25
+ """
26
+ Deep merge two dictionaries, with override taking precedence.
27
+
28
+ Args:
29
+ base: Base dictionary with default values
30
+ override: Dictionary with override values
31
+
32
+ Returns:
33
+ Merged dictionary where override values take precedence
34
+ """
35
+ result = base.copy()
36
+ for key, value in override.items():
37
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
38
+ result[key] = deep_merge(result[key], value)
39
+ else:
40
+ result[key] = value
41
+ return result
42
+
43
+
44
+ class ValidatorConfig:
45
+ """Main configuration object for the validator."""
46
+
47
+ def __init__(self, config_dict: dict[str, Any] | None = None, use_defaults: bool = True):
48
+ """
49
+ Initialize configuration from a dictionary.
50
+
51
+ Args:
52
+ config_dict: Dictionary loaded from YAML config file.
53
+ If None, either uses default configuration (if use_defaults=True)
54
+ or creates an empty configuration (if use_defaults=False).
55
+ If provided, merges with defaults (user config takes precedence).
56
+ use_defaults: Whether to load default configuration. Set to False for testing
57
+ or when you want an empty configuration.
58
+ """
59
+ # Start with default configuration if requested
60
+ if use_defaults:
61
+ default_config = get_default_config()
62
+ # Merge user config with defaults if provided
63
+ if config_dict:
64
+ self.config_dict = deep_merge(default_config, config_dict)
65
+ else:
66
+ self.config_dict = default_config
67
+ else:
68
+ # No defaults - use provided config or empty dict
69
+ self.config_dict = config_dict or {}
70
+
71
+ # Support both nested and flat structure
72
+ # 1. Old nested structure: all checks under "checks" key
73
+ # 2. New flat structure: each check is a top-level key ending with "_check"
74
+ # 3. Default config structure: check IDs directly at top level (without "_check" suffix)
75
+ if "checks" in self.config_dict:
76
+ # Old nested structure
77
+ self.checks_config = self.config_dict.get("checks", {})
78
+ else:
79
+ # New flat structure and default config structure
80
+ # Extract all keys ending with "_check" OR that look like check configurations
81
+ self.checks_config = {}
82
+
83
+ # First, add keys ending with "_check"
84
+ for key, value in self.config_dict.items():
85
+ if key.endswith("_check") and isinstance(value, dict):
86
+ self.checks_config[key.replace("_check", "")] = value
87
+
88
+ # Then, add top-level keys that look like check configurations
89
+ # (they have dict values and contain typical check config keys like enabled, severity, etc.)
90
+ for key, value in self.config_dict.items():
91
+ if (
92
+ key
93
+ not in [
94
+ "settings",
95
+ "custom_checks",
96
+ "custom_checks_dir",
97
+ ] # Skip special config keys
98
+ and not key.endswith("_check") # Skip if already processed above
99
+ and isinstance(value, dict) # Must be a dict
100
+ and key not in self.checks_config # Not already added
101
+ ):
102
+ # This looks like a check configuration
103
+ self.checks_config[key] = value
104
+
105
+ self.custom_checks = self.config_dict.get("custom_checks", [])
106
+ self.custom_checks_dir = self.config_dict.get("custom_checks_dir")
107
+ self.settings = self.config_dict.get("settings", {})
108
+
109
+ def get_check_config(self, check_id: str) -> dict[str, Any]:
110
+ """Get configuration for a specific check."""
111
+ return self.checks_config.get(check_id, {})
112
+
113
+ def is_check_enabled(self, check_id: str) -> bool:
114
+ """Check if a specific check is enabled."""
115
+ check_config = self.get_check_config(check_id)
116
+ return check_config.get("enabled", True)
117
+
118
+ def get_check_severity(self, check_id: str) -> str | None:
119
+ """Get severity override for a check."""
120
+ check_config = self.get_check_config(check_id)
121
+ return check_config.get("severity")
122
+
123
+ def get_setting(self, key: str, default: Any = None) -> Any:
124
+ """Get a global setting value."""
125
+ return self.settings.get(key, default)
126
+
127
+
128
+ class ConfigLoader:
129
+ """Loads configuration from various sources."""
130
+
131
+ # Load default config names from constants module
132
+ DEFAULT_CONFIG_NAMES = DEFAULT_CONFIG_FILENAMES
133
+
134
+ @staticmethod
135
+ def find_config_file(
136
+ explicit_path: str | None = None, search_path: Path | None = None
137
+ ) -> Path | None:
138
+ """
139
+ Find configuration file.
140
+
141
+ Search order:
142
+ 1. Explicit path if provided
143
+ 2. Current directory
144
+ 3. Parent directories (walk up to root)
145
+ 4. User home directory
146
+
147
+ Args:
148
+ explicit_path: Explicit config file path
149
+ search_path: Starting directory for search (defaults to cwd)
150
+
151
+ Returns:
152
+ Path to config file or None if not found
153
+ """
154
+ # Check explicit path first
155
+ if explicit_path:
156
+ path = Path(explicit_path)
157
+ if path.exists() and path.is_file():
158
+ return path
159
+ raise FileNotFoundError(f"Config file not found: {explicit_path}")
160
+
161
+ # Start from search path or current directory
162
+ current = search_path or Path.cwd()
163
+
164
+ # Search current and parent directories
165
+ while True:
166
+ for config_name in ConfigLoader.DEFAULT_CONFIG_NAMES:
167
+ config_path = current / config_name
168
+ if config_path.exists() and config_path.is_file():
169
+ return config_path
170
+
171
+ # Stop at filesystem root
172
+ parent = current.parent
173
+ if parent == current:
174
+ break
175
+ current = parent
176
+
177
+ # Check home directory
178
+ home = Path.home()
179
+ for config_name in ConfigLoader.DEFAULT_CONFIG_NAMES:
180
+ config_path = home / config_name
181
+ if config_path.exists() and config_path.is_file():
182
+ return config_path
183
+
184
+ return None
185
+
186
+ @staticmethod
187
+ def load_yaml(file_path: Path) -> dict[str, Any]:
188
+ """
189
+ Load YAML configuration file.
190
+
191
+ Args:
192
+ file_path: Path to YAML file
193
+
194
+ Returns:
195
+ Parsed configuration dictionary
196
+ """
197
+ try:
198
+ with open(file_path) as f:
199
+ config = yaml.safe_load(f)
200
+ return config or {}
201
+ except yaml.YAMLError as e:
202
+ raise ValueError(f"Invalid YAML in config file {file_path}: {e}")
203
+ except Exception as e:
204
+ raise ValueError(f"Error reading config file {file_path}: {e}")
205
+
206
+ @staticmethod
207
+ def load_config(
208
+ explicit_path: str | None = None,
209
+ search_path: Path | None = None,
210
+ allow_missing: bool = True,
211
+ ) -> ValidatorConfig:
212
+ """
213
+ Load configuration from file.
214
+
215
+ Args:
216
+ explicit_path: Explicit path to config file
217
+ search_path: Starting directory for config search
218
+ allow_missing: If True, return default config when file not found
219
+
220
+ Returns:
221
+ ValidatorConfig object
222
+
223
+ Raises:
224
+ FileNotFoundError: If config not found and allow_missing=False
225
+ """
226
+ config_file = ConfigLoader.find_config_file(explicit_path, search_path)
227
+
228
+ if not config_file:
229
+ if allow_missing:
230
+ return ValidatorConfig() # Return default config
231
+ raise FileNotFoundError(
232
+ f"No configuration file found. Searched for: {', '.join(ConfigLoader.DEFAULT_CONFIG_NAMES)}"
233
+ )
234
+
235
+ config_dict = ConfigLoader.load_yaml(config_file)
236
+ return ValidatorConfig(config_dict)
237
+
238
+ @staticmethod
239
+ def apply_config_to_registry(config: ValidatorConfig, registry: CheckRegistry) -> None:
240
+ """
241
+ Apply configuration to a check registry.
242
+
243
+ This configures all registered checks based on the loaded configuration.
244
+
245
+ Args:
246
+ config: Loaded configuration
247
+ registry: Check registry to configure
248
+ """
249
+ # Configure built-in checks
250
+ for check in registry.get_all_checks():
251
+ check_id = check.check_id
252
+ check_config_dict = config.get_check_config(check_id)
253
+
254
+ # Get existing config to preserve defaults set during registration
255
+ existing_config = registry.get_config(check_id)
256
+ existing_enabled = existing_config.enabled if existing_config else True
257
+
258
+ # Create CheckConfig object
259
+ # If there's explicit config, use it; otherwise preserve existing enabled state
260
+ check_config = CheckConfig(
261
+ check_id=check_id,
262
+ enabled=check_config_dict.get("enabled", existing_enabled),
263
+ severity=check_config_dict.get("severity"),
264
+ config=check_config_dict,
265
+ description=check_config_dict.get("description", check.description),
266
+ root_config=config.config_dict, # Pass full config for cross-check access
267
+ ignore_patterns=check_config_dict.get(
268
+ "ignore_patterns", []
269
+ ), # NEW: Ignore patterns
270
+ )
271
+
272
+ registry.configure_check(check_id, check_config)
273
+
274
+ @staticmethod
275
+ def load_custom_checks(config: ValidatorConfig, registry: CheckRegistry) -> list[str]:
276
+ """
277
+ Load custom checks from Python modules.
278
+
279
+ Args:
280
+ config: Loaded configuration
281
+ registry: Check registry to add custom checks to
282
+
283
+ Returns:
284
+ List of loaded check IDs
285
+
286
+ Note:
287
+ Custom check modules should export a class that inherits from PolicyCheck.
288
+ The module path should be importable (e.g., "my_package.my_check.MyCheck").
289
+ """
290
+ loaded_checks = []
291
+
292
+ # Handle None or missing custom_checks
293
+ if not config.custom_checks:
294
+ return loaded_checks
295
+
296
+ for custom_check_config in config.custom_checks:
297
+ if not custom_check_config.get("enabled", True):
298
+ continue
299
+
300
+ module_path = custom_check_config.get("module")
301
+ if not module_path:
302
+ continue
303
+
304
+ try:
305
+ # Dynamic import of custom check class
306
+ # Format: "package.module.ClassName"
307
+ parts = module_path.rsplit(".", 1)
308
+ if len(parts) != 2:
309
+ raise ValueError(
310
+ f"Invalid module path: {module_path}. "
311
+ "Expected format: 'package.module.ClassName'"
312
+ )
313
+
314
+ module_name, class_name = parts
315
+
316
+ # Import the module
317
+ import importlib
318
+
319
+ module = importlib.import_module(module_name)
320
+ check_class = getattr(module, class_name)
321
+
322
+ # Instantiate and register the check
323
+ check_instance = check_class()
324
+ registry.register(check_instance)
325
+
326
+ # Configure the check
327
+ check_config = CheckConfig(
328
+ check_id=check_instance.check_id,
329
+ enabled=True,
330
+ severity=custom_check_config.get("severity"),
331
+ config=custom_check_config.get("config", {}),
332
+ description=custom_check_config.get("description", check_instance.description),
333
+ )
334
+ registry.configure_check(check_instance.check_id, check_config)
335
+
336
+ loaded_checks.append(check_instance.check_id)
337
+
338
+ except Exception as e:
339
+ # Log error but continue loading other checks
340
+ print(f"Warning: Failed to load custom check '{module_path}': {e}")
341
+
342
+ return loaded_checks
343
+
344
+ @staticmethod
345
+ def discover_checks_in_directory(directory: Path, registry: CheckRegistry) -> list[str]:
346
+ """
347
+ Auto-discover and load custom checks from a directory.
348
+
349
+ This method scans a directory for Python files, imports them,
350
+ and automatically registers any PolicyCheck subclasses found.
351
+
352
+ Args:
353
+ directory: Path to directory containing custom check modules
354
+ registry: Check registry to add discovered checks to
355
+
356
+ Returns:
357
+ List of loaded check IDs
358
+
359
+ Note:
360
+ - All .py files in the directory will be scanned (non-recursive)
361
+ - Files starting with '_' or '.' are skipped
362
+ - Each file can contain multiple PolicyCheck subclasses
363
+ - Classes must inherit from PolicyCheck and implement required methods
364
+ """
365
+ loaded_checks = []
366
+
367
+ if not directory.exists():
368
+ logger.warning(f"Custom checks directory does not exist: {directory}")
369
+ return loaded_checks
370
+
371
+ if not directory.is_dir():
372
+ logger.warning(f"Custom checks path is not a directory: {directory}")
373
+ return loaded_checks
374
+
375
+ logger.info(f"Scanning for custom checks in: {directory}")
376
+
377
+ # Get all Python files in the directory
378
+ python_files = [
379
+ f
380
+ for f in directory.iterdir()
381
+ if f.is_file()
382
+ and f.suffix == ".py"
383
+ and not f.name.startswith("_")
384
+ and not f.name.startswith(".")
385
+ ]
386
+
387
+ for py_file in python_files:
388
+ try:
389
+ # Load module from file
390
+ module_name = f"custom_checks_{py_file.stem}"
391
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
392
+
393
+ if spec is None or spec.loader is None:
394
+ logger.warning(f"Could not load spec from {py_file}")
395
+ continue
396
+
397
+ module = importlib.util.module_from_spec(spec)
398
+
399
+ # Add to sys.modules to support relative imports
400
+ sys.modules[module_name] = module
401
+
402
+ # Execute the module
403
+ spec.loader.exec_module(module)
404
+
405
+ # Find all PolicyCheck subclasses in the module
406
+ for name, obj in inspect.getmembers(module):
407
+ # Skip imported classes and non-classes
408
+ if not inspect.isclass(obj):
409
+ continue
410
+
411
+ # Skip the base PolicyCheck class itself
412
+ if obj is PolicyCheck:
413
+ continue
414
+
415
+ # Check if it's a PolicyCheck subclass
416
+ if issubclass(obj, PolicyCheck) and obj.__module__ == module_name:
417
+ try:
418
+ # Instantiate and register the check
419
+ check_instance = obj()
420
+
421
+ # Verify the check has required properties
422
+ if not hasattr(check_instance, "check_id"):
423
+ logger.warning(
424
+ f"Check class {name} in {py_file} missing check_id property"
425
+ )
426
+ continue
427
+
428
+ registry.register(check_instance)
429
+
430
+ # Create default config (disabled by default - must be explicitly enabled in config)
431
+ check_config = CheckConfig(
432
+ check_id=check_instance.check_id,
433
+ enabled=False,
434
+ description=check_instance.description,
435
+ )
436
+ registry.configure_check(check_instance.check_id, check_config)
437
+
438
+ loaded_checks.append(check_instance.check_id)
439
+ logger.info(
440
+ f"Loaded custom check '{check_instance.check_id}' from {py_file.name}"
441
+ )
442
+
443
+ except Exception as e:
444
+ logger.warning(
445
+ f"Failed to instantiate check {name} from {py_file}: {e}"
446
+ )
447
+
448
+ except Exception as e:
449
+ logger.warning(f"Failed to load custom check module {py_file}: {e}")
450
+
451
+ if loaded_checks:
452
+ logger.info(
453
+ f"Auto-discovered {len(loaded_checks)} custom checks: {', '.join(loaded_checks)}"
454
+ )
455
+
456
+ return loaded_checks
457
+
458
+
459
+ def load_validator_config(
460
+ config_path: str | None = None, allow_missing: bool = True
461
+ ) -> ValidatorConfig:
462
+ """
463
+ Convenience function to load validator configuration.
464
+
465
+ Args:
466
+ config_path: Optional explicit path to config file
467
+ allow_missing: If True, return default config when file not found
468
+
469
+ Returns:
470
+ ValidatorConfig object
471
+ """
472
+ return ConfigLoader.load_config(explicit_path=config_path, allow_missing=allow_missing)