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.
- iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
- iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
- iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +43 -0
- iam_validator/checks/action_condition_enforcement.py +884 -0
- iam_validator/checks/action_resource_matching.py +441 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +92 -0
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +71 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/policy_size.py +147 -0
- iam_validator/checks/policy_type_validation.py +305 -0
- iam_validator/checks/principal_validation.py +776 -0
- iam_validator/checks/resource_validation.py +138 -0
- iam_validator/checks/sensitive_action.py +254 -0
- iam_validator/checks/service_wildcard.py +107 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/checks/wildcard_action.py +67 -0
- iam_validator/checks/wildcard_resource.py +135 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +531 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +600 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +940 -0
- iam_validator/core/check_registry.py +607 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/config/config_loader.py +472 -0
- iam_validator/core/config/defaults.py +523 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/constants.py +74 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +440 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/models.py +327 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +424 -0
- iam_validator/core/report.py +872 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +815 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- 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)
|