iam-policy-validator 1.14.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 (106) hide show
  1. iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
  2. iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
  3. iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.14.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 +9 -0
  9. iam_validator/checks/__init__.py +45 -0
  10. iam_validator/checks/action_condition_enforcement.py +1442 -0
  11. iam_validator/checks/action_resource_matching.py +472 -0
  12. iam_validator/checks/action_validation.py +67 -0
  13. iam_validator/checks/condition_key_validation.py +88 -0
  14. iam_validator/checks/condition_type_mismatch.py +257 -0
  15. iam_validator/checks/full_wildcard.py +62 -0
  16. iam_validator/checks/mfa_condition_check.py +105 -0
  17. iam_validator/checks/policy_size.py +114 -0
  18. iam_validator/checks/policy_structure.py +556 -0
  19. iam_validator/checks/policy_type_validation.py +331 -0
  20. iam_validator/checks/principal_validation.py +708 -0
  21. iam_validator/checks/resource_validation.py +135 -0
  22. iam_validator/checks/sensitive_action.py +438 -0
  23. iam_validator/checks/service_wildcard.py +98 -0
  24. iam_validator/checks/set_operator_validation.py +153 -0
  25. iam_validator/checks/sid_uniqueness.py +146 -0
  26. iam_validator/checks/trust_policy_validation.py +509 -0
  27. iam_validator/checks/utils/__init__.py +17 -0
  28. iam_validator/checks/utils/action_parser.py +149 -0
  29. iam_validator/checks/utils/policy_level_checks.py +190 -0
  30. iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
  31. iam_validator/checks/utils/wildcard_expansion.py +86 -0
  32. iam_validator/checks/wildcard_action.py +58 -0
  33. iam_validator/checks/wildcard_resource.py +374 -0
  34. iam_validator/commands/__init__.py +31 -0
  35. iam_validator/commands/analyze.py +549 -0
  36. iam_validator/commands/base.py +48 -0
  37. iam_validator/commands/cache.py +393 -0
  38. iam_validator/commands/completion.py +471 -0
  39. iam_validator/commands/download_services.py +255 -0
  40. iam_validator/commands/post_to_pr.py +86 -0
  41. iam_validator/commands/query.py +485 -0
  42. iam_validator/commands/validate.py +830 -0
  43. iam_validator/core/__init__.py +13 -0
  44. iam_validator/core/access_analyzer.py +671 -0
  45. iam_validator/core/access_analyzer_report.py +640 -0
  46. iam_validator/core/aws_fetcher.py +29 -0
  47. iam_validator/core/aws_service/__init__.py +21 -0
  48. iam_validator/core/aws_service/cache.py +108 -0
  49. iam_validator/core/aws_service/client.py +205 -0
  50. iam_validator/core/aws_service/fetcher.py +641 -0
  51. iam_validator/core/aws_service/parsers.py +149 -0
  52. iam_validator/core/aws_service/patterns.py +51 -0
  53. iam_validator/core/aws_service/storage.py +291 -0
  54. iam_validator/core/aws_service/validators.py +380 -0
  55. iam_validator/core/check_registry.py +679 -0
  56. iam_validator/core/cli.py +134 -0
  57. iam_validator/core/codeowners.py +245 -0
  58. iam_validator/core/condition_validators.py +626 -0
  59. iam_validator/core/config/__init__.py +81 -0
  60. iam_validator/core/config/aws_api.py +35 -0
  61. iam_validator/core/config/aws_global_conditions.py +160 -0
  62. iam_validator/core/config/category_suggestions.py +181 -0
  63. iam_validator/core/config/check_documentation.py +390 -0
  64. iam_validator/core/config/condition_requirements.py +258 -0
  65. iam_validator/core/config/config_loader.py +670 -0
  66. iam_validator/core/config/defaults.py +739 -0
  67. iam_validator/core/config/principal_requirements.py +421 -0
  68. iam_validator/core/config/sensitive_actions.py +672 -0
  69. iam_validator/core/config/service_principals.py +132 -0
  70. iam_validator/core/config/wildcards.py +127 -0
  71. iam_validator/core/constants.py +149 -0
  72. iam_validator/core/diff_parser.py +325 -0
  73. iam_validator/core/finding_fingerprint.py +131 -0
  74. iam_validator/core/formatters/__init__.py +27 -0
  75. iam_validator/core/formatters/base.py +147 -0
  76. iam_validator/core/formatters/console.py +68 -0
  77. iam_validator/core/formatters/csv.py +171 -0
  78. iam_validator/core/formatters/enhanced.py +481 -0
  79. iam_validator/core/formatters/html.py +672 -0
  80. iam_validator/core/formatters/json.py +33 -0
  81. iam_validator/core/formatters/markdown.py +64 -0
  82. iam_validator/core/formatters/sarif.py +251 -0
  83. iam_validator/core/ignore_patterns.py +297 -0
  84. iam_validator/core/ignore_processor.py +309 -0
  85. iam_validator/core/ignored_findings.py +400 -0
  86. iam_validator/core/label_manager.py +197 -0
  87. iam_validator/core/models.py +404 -0
  88. iam_validator/core/policy_checks.py +220 -0
  89. iam_validator/core/policy_loader.py +785 -0
  90. iam_validator/core/pr_commenter.py +780 -0
  91. iam_validator/core/report.py +942 -0
  92. iam_validator/integrations/__init__.py +28 -0
  93. iam_validator/integrations/github_integration.py +1821 -0
  94. iam_validator/integrations/ms_teams.py +442 -0
  95. iam_validator/sdk/__init__.py +220 -0
  96. iam_validator/sdk/arn_matching.py +382 -0
  97. iam_validator/sdk/context.py +222 -0
  98. iam_validator/sdk/exceptions.py +48 -0
  99. iam_validator/sdk/helpers.py +177 -0
  100. iam_validator/sdk/policy_utils.py +451 -0
  101. iam_validator/sdk/query_utils.py +454 -0
  102. iam_validator/sdk/shortcuts.py +283 -0
  103. iam_validator/utils/__init__.py +35 -0
  104. iam_validator/utils/cache.py +105 -0
  105. iam_validator/utils/regex.py +205 -0
  106. iam_validator/utils/terminal.py +22 -0
@@ -0,0 +1,670 @@
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
9
+ import importlib.util
10
+ import inspect
11
+ import logging
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
18
+
19
+ from iam_validator.core.check_registry import CheckConfig, CheckRegistry, PolicyCheck
20
+ from iam_validator.core.config.defaults import get_default_config
21
+ from iam_validator.core.constants import DEFAULT_CONFIG_FILENAMES
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Valid severity levels for validation
26
+ SEVERITY_LEVELS = frozenset(["error", "warning", "info", "critical", "high", "medium", "low"])
27
+
28
+ # Known built-in check IDs for validation warnings
29
+ KNOWN_CHECK_IDS = frozenset(
30
+ [
31
+ "action_validation",
32
+ "condition_key_validation",
33
+ "condition_type_mismatch",
34
+ "resource_validation",
35
+ "sid_uniqueness",
36
+ "policy_size",
37
+ "policy_structure",
38
+ "set_operator_validation",
39
+ "mfa_condition_check",
40
+ "principal_validation",
41
+ "policy_type_validation",
42
+ "action_resource_matching",
43
+ "trust_policy_validation",
44
+ "wildcard_action",
45
+ "wildcard_resource",
46
+ "full_wildcard",
47
+ "service_wildcard",
48
+ "sensitive_action",
49
+ "action_condition_enforcement",
50
+ ]
51
+ )
52
+
53
+
54
+ # =============================================================================
55
+ # Pydantic Configuration Schemas
56
+ # =============================================================================
57
+
58
+
59
+ class IgnorePatternSchema(BaseModel):
60
+ """Schema for ignore patterns within check configurations."""
61
+
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ # At least one of these should be specified
65
+ file: str | None = None
66
+ action: str | None = None
67
+ resource: str | None = None
68
+ sid: str | None = None
69
+
70
+
71
+ class CheckConfigSchema(BaseModel):
72
+ """Flexible check config - validates core fields, allows extras for custom options.
73
+
74
+ This schema validates common check configuration fields while allowing
75
+ arbitrary additional options that specific checks may require (e.g.,
76
+ allowed_wildcards, categories, requirements).
77
+ """
78
+
79
+ model_config = ConfigDict(extra="allow") # Allow arbitrary check-specific options
80
+
81
+ enabled: bool = True
82
+ severity: str | None = None
83
+ description: str | None = None
84
+ ignore_patterns: list[dict[str, Any]] = []
85
+
86
+ @field_validator("severity")
87
+ @classmethod
88
+ def validate_severity(cls, v: str | None) -> str | None:
89
+ if v is not None and v not in SEVERITY_LEVELS:
90
+ raise ValueError(f"Invalid severity: {v}. Must be one of: {sorted(SEVERITY_LEVELS)}")
91
+ return v
92
+
93
+
94
+ class IgnoreSettingsSchema(BaseModel):
95
+ """Schema for ignore settings."""
96
+
97
+ model_config = ConfigDict(extra="forbid")
98
+
99
+ enabled: bool = True
100
+ allowed_users: list[str] = []
101
+ post_denial_feedback: bool = False
102
+
103
+
104
+ class DocumentationSettingsSchema(BaseModel):
105
+ """Schema for documentation settings."""
106
+
107
+ model_config = ConfigDict(extra="forbid")
108
+
109
+ base_url: str | None = None
110
+ include_aws_docs: bool = True
111
+
112
+
113
+ class SettingsSchema(BaseModel):
114
+ """Schema for global settings."""
115
+
116
+ model_config = ConfigDict(extra="allow") # Allow additional settings
117
+
118
+ fail_fast: bool = False
119
+ parallel: bool = True
120
+ max_workers: int | None = None
121
+ fail_on_severity: list[str] = ["error", "critical"]
122
+ severity_labels: dict[str, str | list[str]] = {}
123
+ ignore_settings: IgnoreSettingsSchema = IgnoreSettingsSchema()
124
+ documentation: DocumentationSettingsSchema = DocumentationSettingsSchema()
125
+
126
+ @field_validator("fail_on_severity")
127
+ @classmethod
128
+ def validate_fail_on_severity(cls, v: list[str]) -> list[str]:
129
+ for severity in v:
130
+ if severity not in SEVERITY_LEVELS:
131
+ raise ValueError(
132
+ f"Invalid severity in fail_on_severity: {severity}. "
133
+ f"Must be one of: {sorted(SEVERITY_LEVELS)}"
134
+ )
135
+ return v
136
+
137
+
138
+ class CustomCheckSchema(BaseModel):
139
+ """Schema for custom check definitions."""
140
+
141
+ model_config = ConfigDict(extra="allow")
142
+
143
+ module: str
144
+ enabled: bool = True
145
+ severity: str | None = None
146
+ description: str | None = None
147
+ config: dict[str, Any] = {}
148
+
149
+ @field_validator("severity")
150
+ @classmethod
151
+ def validate_severity(cls, v: str | None) -> str | None:
152
+ if v is not None and v not in SEVERITY_LEVELS:
153
+ raise ValueError(f"Invalid severity: {v}. Must be one of: {sorted(SEVERITY_LEVELS)}")
154
+ return v
155
+
156
+
157
+ class ConfigSchema(BaseModel):
158
+ """Top-level configuration schema.
159
+
160
+ Validates the overall structure while allowing flexibility for check configs.
161
+ """
162
+
163
+ model_config = ConfigDict(extra="allow") # Allow check configs at top level
164
+
165
+ settings: SettingsSchema = SettingsSchema()
166
+ custom_checks: list[CustomCheckSchema] = []
167
+ custom_checks_dir: str | None = None
168
+
169
+ @model_validator(mode="after")
170
+ def warn_unknown_checks(self) -> "ConfigSchema":
171
+ """Warn about unknown check IDs (potential typos)."""
172
+ # Get all extra fields that might be check configs
173
+ if not self.model_extra:
174
+ return self
175
+
176
+ for key, value in self.model_extra.items():
177
+ if isinstance(value, dict):
178
+ # This looks like a check config
179
+ check_id = key.removesuffix("_check") if key.endswith("_check") else key
180
+ if check_id not in KNOWN_CHECK_IDS:
181
+ logger.warning(
182
+ f"Unknown check ID '{check_id}' in configuration. "
183
+ f"This may be a custom check or a typo."
184
+ )
185
+ return self
186
+
187
+
188
+ class ConfigValidationError(Exception):
189
+ """Raised when configuration validation fails."""
190
+
191
+ def __init__(self, errors: list[str]):
192
+ self.errors = errors
193
+ super().__init__(
194
+ "Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
195
+ )
196
+
197
+
198
+ def validate_config(config_dict: dict[str, Any]) -> tuple[bool, list[str]]:
199
+ """Validate configuration dictionary against schema.
200
+
201
+ Args:
202
+ config_dict: Raw configuration dictionary
203
+
204
+ Returns:
205
+ Tuple of (is_valid, list of error messages)
206
+ """
207
+ errors: list[str] = []
208
+
209
+ try:
210
+ ConfigSchema.model_validate(config_dict)
211
+ except Exception as e:
212
+ # Parse Pydantic validation errors
213
+ if hasattr(e, "errors"):
214
+ for error in e.errors(): # type: ignore
215
+ loc = ".".join(str(x) for x in error.get("loc", []))
216
+ msg = error.get("msg", str(e))
217
+ errors.append(f"{loc}: {msg}")
218
+ else:
219
+ errors.append(str(e))
220
+
221
+ return len(errors) == 0, errors
222
+
223
+
224
+ def deep_merge(base: dict, override: dict) -> dict:
225
+ """
226
+ Deep merge two dictionaries, with override taking precedence.
227
+
228
+ Args:
229
+ base: Base dictionary with default values
230
+ override: Dictionary with override values
231
+
232
+ Returns:
233
+ Merged dictionary where override values take precedence
234
+ """
235
+ result = base.copy()
236
+ for key, value in override.items():
237
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
238
+ result[key] = deep_merge(result[key], value)
239
+ else:
240
+ result[key] = value
241
+ return result
242
+
243
+
244
+ class ValidatorConfig:
245
+ """Main configuration object for the validator."""
246
+
247
+ def __init__(self, config_dict: dict[str, Any] | None = None, use_defaults: bool = True):
248
+ """
249
+ Initialize configuration from a dictionary.
250
+
251
+ Args:
252
+ config_dict: Dictionary loaded from YAML config file.
253
+ If None, either uses default configuration (if use_defaults=True)
254
+ or creates an empty configuration (if use_defaults=False).
255
+ If provided, merges with defaults (user config takes precedence).
256
+ use_defaults: Whether to load default configuration. Set to False for testing
257
+ or when you want an empty configuration.
258
+ """
259
+ # Start with default configuration if requested
260
+ if use_defaults:
261
+ default_config = get_default_config()
262
+ # Merge user config with defaults if provided
263
+ if config_dict:
264
+ self.config_dict = deep_merge(default_config, config_dict)
265
+ else:
266
+ self.config_dict = default_config
267
+ else:
268
+ # No defaults - use provided config or empty dict
269
+ self.config_dict = config_dict or {}
270
+
271
+ # Support both nested and flat structure
272
+ # 1. Old nested structure: all checks under "checks" key
273
+ # 2. New flat structure: each check is a top-level key ending with "_check"
274
+ # 3. Default config structure: check IDs directly at top level (without "_check" suffix)
275
+ if "checks" in self.config_dict:
276
+ # Old nested structure
277
+ self.checks_config = self.config_dict.get("checks", {})
278
+ else:
279
+ # New flat structure and default config structure
280
+ # Extract all keys ending with "_check" OR that look like check configurations
281
+ self.checks_config = {}
282
+
283
+ # First, add keys ending with "_check"
284
+ for key, value in self.config_dict.items():
285
+ if key.endswith("_check") and isinstance(value, dict):
286
+ self.checks_config[key.replace("_check", "")] = value
287
+
288
+ # Then, add top-level keys that look like check configurations
289
+ # (they have dict values and contain typical check config keys like enabled, severity, etc.)
290
+ for key, value in self.config_dict.items():
291
+ if (
292
+ key
293
+ not in [
294
+ "settings",
295
+ "custom_checks",
296
+ "custom_checks_dir",
297
+ ] # Skip special config keys
298
+ and not key.endswith("_check") # Skip if already processed above
299
+ and isinstance(value, dict) # Must be a dict
300
+ and key not in self.checks_config # Not already added
301
+ ):
302
+ # This looks like a check configuration
303
+ self.checks_config[key] = value
304
+
305
+ self.custom_checks = self.config_dict.get("custom_checks", [])
306
+ self.custom_checks_dir = self.config_dict.get("custom_checks_dir")
307
+ self.settings = self.config_dict.get("settings", {})
308
+
309
+ def get_check_config(self, check_id: str) -> dict[str, Any]:
310
+ """Get configuration for a specific check."""
311
+ return self.checks_config.get(check_id, {})
312
+
313
+ def is_check_enabled(self, check_id: str) -> bool:
314
+ """Check if a specific check is enabled."""
315
+ check_config = self.get_check_config(check_id)
316
+ return check_config.get("enabled", True)
317
+
318
+ def get_check_severity(self, check_id: str) -> str | None:
319
+ """Get severity override for a check."""
320
+ check_config = self.get_check_config(check_id)
321
+ return check_config.get("severity")
322
+
323
+ def get_setting(self, key: str, default: Any = None) -> Any:
324
+ """Get a global setting value."""
325
+ return self.settings.get(key, default)
326
+
327
+
328
+ class ConfigLoader:
329
+ """Loads configuration from various sources."""
330
+
331
+ # Load default config names from constants module
332
+ DEFAULT_CONFIG_NAMES = DEFAULT_CONFIG_FILENAMES
333
+
334
+ @staticmethod
335
+ def find_config_file(
336
+ explicit_path: str | None = None, search_path: Path | None = None
337
+ ) -> Path | None:
338
+ """
339
+ Find configuration file.
340
+
341
+ Search order:
342
+ 1. Explicit path if provided
343
+ 2. Current directory
344
+ 3. Parent directories (walk up to root)
345
+ 4. User home directory
346
+
347
+ Args:
348
+ explicit_path: Explicit config file path
349
+ search_path: Starting directory for search (defaults to cwd)
350
+
351
+ Returns:
352
+ Path to config file or None if not found
353
+ """
354
+ # Check explicit path first
355
+ if explicit_path:
356
+ path = Path(explicit_path)
357
+ if path.exists() and path.is_file():
358
+ return path
359
+ raise FileNotFoundError(f"Config file not found: {explicit_path}")
360
+
361
+ # Start from search path or current directory
362
+ current = search_path or Path.cwd()
363
+
364
+ # Search current and parent directories
365
+ while True:
366
+ for config_name in ConfigLoader.DEFAULT_CONFIG_NAMES:
367
+ config_path = current / config_name
368
+ if config_path.exists() and config_path.is_file():
369
+ return config_path
370
+
371
+ # Stop at filesystem root
372
+ parent = current.parent
373
+ if parent == current:
374
+ break
375
+ current = parent
376
+
377
+ # Check home directory
378
+ home = Path.home()
379
+ for config_name in ConfigLoader.DEFAULT_CONFIG_NAMES:
380
+ config_path = home / config_name
381
+ if config_path.exists() and config_path.is_file():
382
+ return config_path
383
+
384
+ return None
385
+
386
+ @staticmethod
387
+ def load_yaml(file_path: Path) -> dict[str, Any]:
388
+ """
389
+ Load YAML configuration file.
390
+
391
+ Args:
392
+ file_path: Path to YAML file
393
+
394
+ Returns:
395
+ Parsed configuration dictionary
396
+ """
397
+ try:
398
+ with open(file_path) as f:
399
+ config = yaml.safe_load(f)
400
+ return config or {}
401
+ except yaml.YAMLError as e:
402
+ raise ValueError(f"Invalid YAML in config file {file_path}: {e}")
403
+ except Exception as e:
404
+ raise ValueError(f"Error reading config file {file_path}: {e}")
405
+
406
+ @staticmethod
407
+ def load_config(
408
+ explicit_path: str | None = None,
409
+ search_path: Path | None = None,
410
+ allow_missing: bool = True,
411
+ ) -> ValidatorConfig:
412
+ """
413
+ Load configuration from file.
414
+
415
+ Args:
416
+ explicit_path: Explicit path to config file
417
+ search_path: Starting directory for config search
418
+ allow_missing: If True, return default config when file not found
419
+
420
+ Returns:
421
+ ValidatorConfig object
422
+
423
+ Raises:
424
+ FileNotFoundError: If config not found and allow_missing=False
425
+ """
426
+ config_file = ConfigLoader.find_config_file(explicit_path, search_path)
427
+
428
+ if not config_file:
429
+ if allow_missing:
430
+ return ValidatorConfig() # Return default config
431
+ raise FileNotFoundError(
432
+ f"No configuration file found. Searched for: {', '.join(ConfigLoader.DEFAULT_CONFIG_NAMES)}"
433
+ )
434
+
435
+ config_dict = ConfigLoader.load_yaml(config_file)
436
+ return ValidatorConfig(config_dict)
437
+
438
+ @staticmethod
439
+ def apply_config_to_registry(config: ValidatorConfig, registry: CheckRegistry) -> None:
440
+ """
441
+ Apply configuration to a check registry.
442
+
443
+ This configures all registered checks based on the loaded configuration.
444
+
445
+ Args:
446
+ config: Loaded configuration
447
+ registry: Check registry to configure
448
+ """
449
+ # Configure built-in checks
450
+ for check in registry.get_all_checks():
451
+ check_id = check.check_id
452
+ check_config_dict = config.get_check_config(check_id)
453
+
454
+ # Get existing config to preserve defaults set during registration
455
+ existing_config = registry.get_config(check_id)
456
+ existing_enabled = existing_config.enabled if existing_config else True
457
+
458
+ # Create CheckConfig object
459
+ # If there's explicit config, use it; otherwise preserve existing enabled state
460
+ check_config = CheckConfig(
461
+ check_id=check_id,
462
+ enabled=check_config_dict.get("enabled", existing_enabled),
463
+ severity=check_config_dict.get("severity"),
464
+ config=check_config_dict,
465
+ description=check_config_dict.get("description", check.description),
466
+ root_config=config.config_dict, # Pass full config for cross-check access
467
+ ignore_patterns=check_config_dict.get(
468
+ "ignore_patterns", []
469
+ ), # NEW: Ignore patterns
470
+ )
471
+
472
+ registry.configure_check(check_id, check_config)
473
+
474
+ @staticmethod
475
+ def load_custom_checks(config: ValidatorConfig, registry: CheckRegistry) -> list[str]:
476
+ """
477
+ Load custom checks from Python modules.
478
+
479
+ Args:
480
+ config: Loaded configuration
481
+ registry: Check registry to add custom checks to
482
+
483
+ Returns:
484
+ List of loaded check IDs
485
+
486
+ Note:
487
+ Custom check modules should export a class that inherits from PolicyCheck.
488
+ The module path should be importable (e.g., "my_package.my_check.MyCheck").
489
+ """
490
+ loaded_checks = []
491
+
492
+ # Handle None or missing custom_checks
493
+ if not config.custom_checks:
494
+ return loaded_checks
495
+
496
+ for custom_check_config in config.custom_checks:
497
+ if not custom_check_config.get("enabled", True):
498
+ continue
499
+
500
+ module_path = custom_check_config.get("module")
501
+ if not module_path:
502
+ continue
503
+
504
+ try:
505
+ # Dynamic import of custom check class
506
+ # Format: "package.module.ClassName"
507
+ parts = module_path.rsplit(".", 1)
508
+ if len(parts) != 2:
509
+ raise ValueError(
510
+ f"Invalid module path: {module_path}. "
511
+ "Expected format: 'package.module.ClassName'"
512
+ )
513
+
514
+ module_name, class_name = parts
515
+
516
+ # Import the module
517
+ module = importlib.import_module(module_name)
518
+ check_class = getattr(module, class_name)
519
+
520
+ # Instantiate and register the check
521
+ check_instance = check_class()
522
+ registry.register(check_instance)
523
+
524
+ # Configure the check
525
+ check_config = CheckConfig(
526
+ check_id=check_instance.check_id,
527
+ enabled=True,
528
+ severity=custom_check_config.get("severity"),
529
+ config=custom_check_config.get("config", {}),
530
+ description=custom_check_config.get("description", check_instance.description),
531
+ )
532
+ registry.configure_check(check_instance.check_id, check_config)
533
+
534
+ loaded_checks.append(check_instance.check_id)
535
+
536
+ except Exception as e:
537
+ # Log error but continue loading other checks
538
+ print(f"Warning: Failed to load custom check '{module_path}': {e}")
539
+
540
+ return loaded_checks
541
+
542
+ @staticmethod
543
+ def discover_checks_in_directory(directory: Path, registry: CheckRegistry) -> list[str]:
544
+ """
545
+ Auto-discover and load custom checks from a directory.
546
+
547
+ This method scans a directory for Python files, imports them,
548
+ and automatically registers any PolicyCheck subclasses found.
549
+
550
+ Args:
551
+ directory: Path to directory containing custom check modules
552
+ registry: Check registry to add discovered checks to
553
+
554
+ Returns:
555
+ List of loaded check IDs
556
+
557
+ Note:
558
+ - All .py files in the directory will be scanned (non-recursive)
559
+ - Files starting with '_' or '.' are skipped
560
+ - Each file can contain multiple PolicyCheck subclasses
561
+ - Classes must inherit from PolicyCheck and implement required methods
562
+ """
563
+ loaded_checks = []
564
+
565
+ if not directory.exists():
566
+ logger.warning(f"Custom checks directory does not exist: {directory}")
567
+ return loaded_checks
568
+
569
+ if not directory.is_dir():
570
+ logger.warning(f"Custom checks path is not a directory: {directory}")
571
+ return loaded_checks
572
+
573
+ logger.info(f"Scanning for custom checks in: {directory}")
574
+
575
+ # Get all Python files in the directory
576
+ python_files = [
577
+ f
578
+ for f in directory.iterdir()
579
+ if f.is_file()
580
+ and f.suffix == ".py"
581
+ and not f.name.startswith("_")
582
+ and not f.name.startswith(".")
583
+ ]
584
+
585
+ for py_file in python_files:
586
+ try:
587
+ # Load module from file
588
+ module_name = f"custom_checks_{py_file.stem}"
589
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
590
+
591
+ if spec is None or spec.loader is None:
592
+ logger.warning(f"Could not load spec from {py_file}")
593
+ continue
594
+
595
+ module = importlib.util.module_from_spec(spec)
596
+
597
+ # Add to sys.modules to support relative imports
598
+ sys.modules[module_name] = module
599
+
600
+ # Execute the module
601
+ spec.loader.exec_module(module)
602
+
603
+ # Find all PolicyCheck subclasses in the module
604
+ for name, obj in inspect.getmembers(module):
605
+ # Skip imported classes and non-classes
606
+ if not inspect.isclass(obj):
607
+ continue
608
+
609
+ # Skip the base PolicyCheck class itself
610
+ if obj is PolicyCheck:
611
+ continue
612
+
613
+ # Check if it's a PolicyCheck subclass
614
+ if issubclass(obj, PolicyCheck) and obj.__module__ == module_name:
615
+ try:
616
+ # Instantiate and register the check
617
+ check_instance = obj()
618
+
619
+ # Verify the check has required properties
620
+ if not hasattr(check_instance, "check_id"):
621
+ logger.warning(
622
+ f"Check class {name} in {py_file} missing check_id property"
623
+ )
624
+ continue
625
+
626
+ registry.register(check_instance)
627
+
628
+ # Create default config (disabled by default - must be explicitly enabled in config)
629
+ check_config = CheckConfig(
630
+ check_id=check_instance.check_id,
631
+ enabled=False,
632
+ description=check_instance.description,
633
+ )
634
+ registry.configure_check(check_instance.check_id, check_config)
635
+
636
+ loaded_checks.append(check_instance.check_id)
637
+ logger.info(
638
+ f"Loaded custom check '{check_instance.check_id}' from {py_file.name}"
639
+ )
640
+
641
+ except Exception as e:
642
+ logger.warning(
643
+ f"Failed to instantiate check {name} from {py_file}: {e}"
644
+ )
645
+
646
+ except Exception as e:
647
+ logger.warning(f"Failed to load custom check module {py_file}: {e}")
648
+
649
+ if loaded_checks:
650
+ logger.info(
651
+ f"Auto-discovered {len(loaded_checks)} custom checks: {', '.join(loaded_checks)}"
652
+ )
653
+
654
+ return loaded_checks
655
+
656
+
657
+ def load_validator_config(
658
+ config_path: str | None = None, allow_missing: bool = True
659
+ ) -> ValidatorConfig:
660
+ """
661
+ Convenience function to load validator configuration.
662
+
663
+ Args:
664
+ config_path: Optional explicit path to config file
665
+ allow_missing: If True, return default config when file not found
666
+
667
+ Returns:
668
+ ValidatorConfig object
669
+ """
670
+ return ConfigLoader.load_config(explicit_path=config_path, allow_missing=allow_missing)