truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""Rule configuration validator with circular reference detection.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive validation for routing rule configurations,
|
|
4
|
+
including:
|
|
5
|
+
- Circular reference detection (direct and indirect)
|
|
6
|
+
- Maximum nesting depth limits
|
|
7
|
+
- Maximum rules per combinator limits
|
|
8
|
+
- Reserved field name validation
|
|
9
|
+
- Rule type validation against RuleRegistry
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
from truthound_dashboard.core.notifications.routing.validator import (
|
|
13
|
+
RuleValidator,
|
|
14
|
+
RuleValidationConfig,
|
|
15
|
+
RuleValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
validator = RuleValidator()
|
|
19
|
+
result = validator.validate(rule_config)
|
|
20
|
+
|
|
21
|
+
if not result.valid:
|
|
22
|
+
for error in result.errors:
|
|
23
|
+
print(f"Error: {error.message} at {error.path}")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationErrorType(str, Enum):
|
|
34
|
+
"""Types of validation errors."""
|
|
35
|
+
|
|
36
|
+
UNKNOWN_RULE_TYPE = "unknown_rule_type"
|
|
37
|
+
MISSING_REQUIRED_FIELD = "missing_required_field"
|
|
38
|
+
INVALID_FIELD_VALUE = "invalid_field_value"
|
|
39
|
+
CIRCULAR_REFERENCE = "circular_reference"
|
|
40
|
+
MAX_DEPTH_EXCEEDED = "max_depth_exceeded"
|
|
41
|
+
MAX_RULES_EXCEEDED = "max_rules_exceeded"
|
|
42
|
+
RESERVED_FIELD_NAME = "reserved_field_name"
|
|
43
|
+
EMPTY_COMBINATOR = "empty_combinator"
|
|
44
|
+
INVALID_STRUCTURE = "invalid_structure"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ValidationError:
|
|
49
|
+
"""A single validation error with context.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
type: The type of validation error.
|
|
53
|
+
message: Human-readable error message.
|
|
54
|
+
path: JSON path to the error location (e.g., "rules[0].rules[1]").
|
|
55
|
+
context: Additional context about the error.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
type: ValidationErrorType
|
|
59
|
+
message: str
|
|
60
|
+
path: str = ""
|
|
61
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
"""Convert to dictionary representation."""
|
|
65
|
+
return {
|
|
66
|
+
"type": self.type.value,
|
|
67
|
+
"message": self.message,
|
|
68
|
+
"path": self.path,
|
|
69
|
+
"context": self.context,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ValidationWarning:
|
|
75
|
+
"""A single validation warning.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
message: Human-readable warning message.
|
|
79
|
+
path: JSON path to the warning location.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
message: str
|
|
83
|
+
path: str = ""
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
"""Convert to dictionary representation."""
|
|
87
|
+
return {
|
|
88
|
+
"message": self.message,
|
|
89
|
+
"path": self.path,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class RuleValidationConfig:
|
|
95
|
+
"""Configuration for rule validation.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
max_depth: Maximum allowed nesting depth (default: 10).
|
|
99
|
+
max_rules_per_combinator: Maximum rules in a single combinator (default: 50).
|
|
100
|
+
max_total_rules: Maximum total rules in the configuration (default: 500).
|
|
101
|
+
reserved_field_names: Set of reserved field names that cannot be used.
|
|
102
|
+
check_circular_refs: Whether to check for circular references (default: True).
|
|
103
|
+
strict_mode: If True, warnings are treated as errors (default: False).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
max_depth: int = 10
|
|
107
|
+
max_rules_per_combinator: int = 50
|
|
108
|
+
max_total_rules: int = 500
|
|
109
|
+
reserved_field_names: set[str] = field(
|
|
110
|
+
default_factory=lambda: {
|
|
111
|
+
"__proto__",
|
|
112
|
+
"constructor",
|
|
113
|
+
"prototype",
|
|
114
|
+
"__class__",
|
|
115
|
+
"__bases__",
|
|
116
|
+
"__mro__",
|
|
117
|
+
"__subclasses__",
|
|
118
|
+
"__init__",
|
|
119
|
+
"__new__",
|
|
120
|
+
"__del__",
|
|
121
|
+
"__call__",
|
|
122
|
+
"__getattr__",
|
|
123
|
+
"__setattr__",
|
|
124
|
+
"__delattr__",
|
|
125
|
+
"__dict__",
|
|
126
|
+
"__slots__",
|
|
127
|
+
"__module__",
|
|
128
|
+
"__name__",
|
|
129
|
+
"__qualname__",
|
|
130
|
+
"__globals__",
|
|
131
|
+
"__code__",
|
|
132
|
+
"__builtins__",
|
|
133
|
+
"__import__",
|
|
134
|
+
"eval",
|
|
135
|
+
"exec",
|
|
136
|
+
"compile",
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
check_circular_refs: bool = True
|
|
140
|
+
strict_mode: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class RuleValidationResult:
|
|
145
|
+
"""Result of rule configuration validation.
|
|
146
|
+
|
|
147
|
+
Attributes:
|
|
148
|
+
valid: Whether the configuration is valid.
|
|
149
|
+
errors: List of validation errors.
|
|
150
|
+
warnings: List of validation warnings.
|
|
151
|
+
rule_count: Total number of rules (including nested).
|
|
152
|
+
max_depth: Maximum nesting depth found.
|
|
153
|
+
circular_paths: Paths of detected circular references.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
valid: bool = True
|
|
157
|
+
errors: list[ValidationError] = field(default_factory=list)
|
|
158
|
+
warnings: list[ValidationWarning] = field(default_factory=list)
|
|
159
|
+
rule_count: int = 0
|
|
160
|
+
max_depth: int = 0
|
|
161
|
+
circular_paths: list[str] = field(default_factory=list)
|
|
162
|
+
|
|
163
|
+
def add_error(
|
|
164
|
+
self,
|
|
165
|
+
error_type: ValidationErrorType,
|
|
166
|
+
message: str,
|
|
167
|
+
path: str = "",
|
|
168
|
+
context: dict[str, Any] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Add a validation error."""
|
|
171
|
+
self.valid = False
|
|
172
|
+
self.errors.append(
|
|
173
|
+
ValidationError(
|
|
174
|
+
type=error_type,
|
|
175
|
+
message=message,
|
|
176
|
+
path=path,
|
|
177
|
+
context=context or {},
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def add_warning(self, message: str, path: str = "") -> None:
|
|
182
|
+
"""Add a validation warning."""
|
|
183
|
+
self.warnings.append(ValidationWarning(message=message, path=path))
|
|
184
|
+
|
|
185
|
+
def to_dict(self) -> dict[str, Any]:
|
|
186
|
+
"""Convert to dictionary representation."""
|
|
187
|
+
return {
|
|
188
|
+
"valid": self.valid,
|
|
189
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
190
|
+
"warnings": [w.to_dict() for w in self.warnings],
|
|
191
|
+
"rule_count": self.rule_count,
|
|
192
|
+
"max_depth": self.max_depth,
|
|
193
|
+
"circular_paths": self.circular_paths,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def error_messages(self) -> list[str]:
|
|
197
|
+
"""Get list of error messages for backward compatibility."""
|
|
198
|
+
return [e.message for e in self.errors]
|
|
199
|
+
|
|
200
|
+
def warning_messages(self) -> list[str]:
|
|
201
|
+
"""Get list of warning messages for backward compatibility."""
|
|
202
|
+
return [w.message for w in self.warnings]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class RuleValidationError(Exception):
|
|
206
|
+
"""Exception raised for critical validation failures.
|
|
207
|
+
|
|
208
|
+
Attributes:
|
|
209
|
+
result: The validation result with error details.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, message: str, result: RuleValidationResult):
|
|
213
|
+
super().__init__(message)
|
|
214
|
+
self.result = result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class RuleValidator:
|
|
218
|
+
"""Comprehensive validator for routing rule configurations.
|
|
219
|
+
|
|
220
|
+
Provides validation including:
|
|
221
|
+
- Rule type existence (via RuleRegistry)
|
|
222
|
+
- Required parameter validation
|
|
223
|
+
- Circular reference detection (direct and indirect)
|
|
224
|
+
- Maximum nesting depth enforcement
|
|
225
|
+
- Maximum rules per combinator enforcement
|
|
226
|
+
- Reserved field name checking
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
validator = RuleValidator()
|
|
230
|
+
result = validator.validate({
|
|
231
|
+
"type": "all_of",
|
|
232
|
+
"rules": [
|
|
233
|
+
{"type": "severity", "params": {"min_severity": "critical"}},
|
|
234
|
+
{"type": "tag", "params": {"tags": ["production"]}},
|
|
235
|
+
]
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
if result.valid:
|
|
239
|
+
print("Configuration is valid")
|
|
240
|
+
else:
|
|
241
|
+
for error in result.errors:
|
|
242
|
+
print(f"Error: {error.message}")
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# Combinator types that can contain nested rules
|
|
246
|
+
COMBINATOR_TYPES = {"all_of", "any_of", "not"}
|
|
247
|
+
|
|
248
|
+
def __init__(self, config: RuleValidationConfig | None = None):
|
|
249
|
+
"""Initialize the validator.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
config: Validation configuration. Uses defaults if not provided.
|
|
253
|
+
"""
|
|
254
|
+
self.config = config or RuleValidationConfig()
|
|
255
|
+
|
|
256
|
+
def validate(
|
|
257
|
+
self,
|
|
258
|
+
rule_config: dict[str, Any],
|
|
259
|
+
config_override: RuleValidationConfig | None = None,
|
|
260
|
+
) -> RuleValidationResult:
|
|
261
|
+
"""Validate a rule configuration.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
rule_config: The rule configuration dictionary to validate.
|
|
265
|
+
config_override: Optional configuration override for this validation.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
RuleValidationResult with validation details.
|
|
269
|
+
"""
|
|
270
|
+
config = config_override or self.config
|
|
271
|
+
result = RuleValidationResult()
|
|
272
|
+
|
|
273
|
+
# Track visited nodes for circular reference detection
|
|
274
|
+
# Using a path-based approach for accurate cycle detection
|
|
275
|
+
visited_paths: set[str] = set()
|
|
276
|
+
|
|
277
|
+
# Perform recursive validation
|
|
278
|
+
self._validate_rule_recursive(
|
|
279
|
+
rule_config=rule_config,
|
|
280
|
+
result=result,
|
|
281
|
+
config=config,
|
|
282
|
+
depth=0,
|
|
283
|
+
path="",
|
|
284
|
+
visited_paths=visited_paths,
|
|
285
|
+
parent_chain=[],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Apply strict mode if configured
|
|
289
|
+
if config.strict_mode and result.warnings:
|
|
290
|
+
for warning in result.warnings:
|
|
291
|
+
result.add_error(
|
|
292
|
+
ValidationErrorType.INVALID_FIELD_VALUE,
|
|
293
|
+
f"Strict mode: {warning.message}",
|
|
294
|
+
warning.path,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
def _validate_rule_recursive(
|
|
300
|
+
self,
|
|
301
|
+
rule_config: dict[str, Any],
|
|
302
|
+
result: RuleValidationResult,
|
|
303
|
+
config: RuleValidationConfig,
|
|
304
|
+
depth: int,
|
|
305
|
+
path: str,
|
|
306
|
+
visited_paths: set[str],
|
|
307
|
+
parent_chain: list[str],
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Recursively validate a rule configuration.
|
|
310
|
+
|
|
311
|
+
Uses DFS with visited set for efficient cycle detection.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
rule_config: The rule configuration to validate.
|
|
315
|
+
result: Validation result to update.
|
|
316
|
+
config: Validation configuration.
|
|
317
|
+
depth: Current nesting depth.
|
|
318
|
+
path: Current JSON path for error reporting.
|
|
319
|
+
visited_paths: Set of visited paths for cycle detection.
|
|
320
|
+
parent_chain: Chain of parent rule IDs for cycle path reporting.
|
|
321
|
+
"""
|
|
322
|
+
from .rules import RuleRegistry
|
|
323
|
+
|
|
324
|
+
result.rule_count += 1
|
|
325
|
+
result.max_depth = max(result.max_depth, depth)
|
|
326
|
+
|
|
327
|
+
# Check total rules limit
|
|
328
|
+
if result.rule_count > config.max_total_rules:
|
|
329
|
+
result.add_error(
|
|
330
|
+
ValidationErrorType.MAX_RULES_EXCEEDED,
|
|
331
|
+
f"Total rule count ({result.rule_count}) exceeds maximum ({config.max_total_rules})",
|
|
332
|
+
path,
|
|
333
|
+
{"max_total_rules": config.max_total_rules, "current_count": result.rule_count},
|
|
334
|
+
)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
# Validate structure
|
|
338
|
+
if not isinstance(rule_config, dict):
|
|
339
|
+
result.add_error(
|
|
340
|
+
ValidationErrorType.INVALID_STRUCTURE,
|
|
341
|
+
"Rule configuration must be a dictionary/object",
|
|
342
|
+
path,
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Get rule type
|
|
347
|
+
rule_type = rule_config.get("type")
|
|
348
|
+
if not rule_type:
|
|
349
|
+
result.add_error(
|
|
350
|
+
ValidationErrorType.MISSING_REQUIRED_FIELD,
|
|
351
|
+
"Rule missing required field 'type'",
|
|
352
|
+
path,
|
|
353
|
+
)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# Validate rule type string
|
|
357
|
+
if not isinstance(rule_type, str):
|
|
358
|
+
result.add_error(
|
|
359
|
+
ValidationErrorType.INVALID_FIELD_VALUE,
|
|
360
|
+
f"Rule 'type' must be a string, got {type(rule_type).__name__}",
|
|
361
|
+
path,
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# Check for reserved field names in params
|
|
366
|
+
params = rule_config.get("params", {})
|
|
367
|
+
if isinstance(params, dict):
|
|
368
|
+
self._check_reserved_fields(params, result, config, f"{path}.params")
|
|
369
|
+
|
|
370
|
+
# Check nesting depth
|
|
371
|
+
if depth > config.max_depth:
|
|
372
|
+
result.add_error(
|
|
373
|
+
ValidationErrorType.MAX_DEPTH_EXCEEDED,
|
|
374
|
+
f"Maximum nesting depth ({config.max_depth}) exceeded at depth {depth}",
|
|
375
|
+
path,
|
|
376
|
+
{"max_depth": config.max_depth, "current_depth": depth},
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Create a unique identifier for this rule node
|
|
381
|
+
rule_id = self._generate_rule_id(rule_config, path)
|
|
382
|
+
|
|
383
|
+
# Circular reference detection using path-based tracking
|
|
384
|
+
if config.check_circular_refs:
|
|
385
|
+
if rule_id in visited_paths:
|
|
386
|
+
# Found a circular reference
|
|
387
|
+
cycle_path = self._format_cycle_path(parent_chain, rule_id)
|
|
388
|
+
result.add_error(
|
|
389
|
+
ValidationErrorType.CIRCULAR_REFERENCE,
|
|
390
|
+
f"Circular reference detected: {cycle_path}",
|
|
391
|
+
path,
|
|
392
|
+
{"cycle_path": cycle_path, "rule_id": rule_id},
|
|
393
|
+
)
|
|
394
|
+
result.circular_paths.append(cycle_path)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# Mark as visited
|
|
398
|
+
visited_paths.add(rule_id)
|
|
399
|
+
current_chain = parent_chain + [rule_id]
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
# Validate based on rule type
|
|
403
|
+
if rule_type in self.COMBINATOR_TYPES:
|
|
404
|
+
self._validate_combinator(
|
|
405
|
+
rule_config=rule_config,
|
|
406
|
+
rule_type=rule_type,
|
|
407
|
+
result=result,
|
|
408
|
+
config=config,
|
|
409
|
+
depth=depth,
|
|
410
|
+
path=path,
|
|
411
|
+
visited_paths=visited_paths,
|
|
412
|
+
parent_chain=current_chain,
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
self._validate_simple_rule(
|
|
416
|
+
rule_config=rule_config,
|
|
417
|
+
rule_type=rule_type,
|
|
418
|
+
result=result,
|
|
419
|
+
path=path,
|
|
420
|
+
)
|
|
421
|
+
finally:
|
|
422
|
+
# Unmark as visited (for DFS backtracking)
|
|
423
|
+
# This allows the same rule pattern to appear in different branches
|
|
424
|
+
visited_paths.discard(rule_id)
|
|
425
|
+
|
|
426
|
+
def _validate_combinator(
|
|
427
|
+
self,
|
|
428
|
+
rule_config: dict[str, Any],
|
|
429
|
+
rule_type: str,
|
|
430
|
+
result: RuleValidationResult,
|
|
431
|
+
config: RuleValidationConfig,
|
|
432
|
+
depth: int,
|
|
433
|
+
path: str,
|
|
434
|
+
visited_paths: set[str],
|
|
435
|
+
parent_chain: list[str],
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Validate a combinator rule (all_of, any_of, not).
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
rule_config: The combinator rule configuration.
|
|
441
|
+
rule_type: The combinator type.
|
|
442
|
+
result: Validation result to update.
|
|
443
|
+
config: Validation configuration.
|
|
444
|
+
depth: Current nesting depth.
|
|
445
|
+
path: Current JSON path.
|
|
446
|
+
visited_paths: Set of visited paths.
|
|
447
|
+
parent_chain: Chain of parent rule IDs.
|
|
448
|
+
"""
|
|
449
|
+
if rule_type == "not":
|
|
450
|
+
# NOT combinator requires a single 'rule' field
|
|
451
|
+
nested_rule = rule_config.get("rule") or rule_config.get("params", {}).get("rule")
|
|
452
|
+
|
|
453
|
+
if not nested_rule:
|
|
454
|
+
result.add_error(
|
|
455
|
+
ValidationErrorType.EMPTY_COMBINATOR,
|
|
456
|
+
"'not' combinator requires a 'rule' field",
|
|
457
|
+
path,
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if not isinstance(nested_rule, dict):
|
|
462
|
+
result.add_error(
|
|
463
|
+
ValidationErrorType.INVALID_STRUCTURE,
|
|
464
|
+
"'not' combinator 'rule' must be an object",
|
|
465
|
+
path,
|
|
466
|
+
)
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
# Recursively validate the nested rule
|
|
470
|
+
self._validate_rule_recursive(
|
|
471
|
+
rule_config=nested_rule,
|
|
472
|
+
result=result,
|
|
473
|
+
config=config,
|
|
474
|
+
depth=depth + 1,
|
|
475
|
+
path=f"{path}.rule" if path else "rule",
|
|
476
|
+
visited_paths=visited_paths,
|
|
477
|
+
parent_chain=parent_chain,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
else:
|
|
481
|
+
# ALL_OF or ANY_OF combinator requires a 'rules' array
|
|
482
|
+
nested_rules = rule_config.get("rules") or rule_config.get("params", {}).get("rules", [])
|
|
483
|
+
|
|
484
|
+
if not nested_rules:
|
|
485
|
+
result.add_error(
|
|
486
|
+
ValidationErrorType.EMPTY_COMBINATOR,
|
|
487
|
+
f"'{rule_type}' combinator requires a non-empty 'rules' array",
|
|
488
|
+
path,
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
if not isinstance(nested_rules, list):
|
|
493
|
+
result.add_error(
|
|
494
|
+
ValidationErrorType.INVALID_STRUCTURE,
|
|
495
|
+
f"'{rule_type}' combinator 'rules' must be an array",
|
|
496
|
+
path,
|
|
497
|
+
)
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# Check max rules per combinator
|
|
501
|
+
if len(nested_rules) > config.max_rules_per_combinator:
|
|
502
|
+
result.add_error(
|
|
503
|
+
ValidationErrorType.MAX_RULES_EXCEEDED,
|
|
504
|
+
f"Combinator has {len(nested_rules)} rules, exceeds maximum of {config.max_rules_per_combinator}",
|
|
505
|
+
path,
|
|
506
|
+
{
|
|
507
|
+
"max_rules_per_combinator": config.max_rules_per_combinator,
|
|
508
|
+
"current_count": len(nested_rules),
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# Warn about single-rule combinators
|
|
514
|
+
if len(nested_rules) == 1:
|
|
515
|
+
result.add_warning(
|
|
516
|
+
f"'{rule_type}' combinator with only 1 rule is redundant",
|
|
517
|
+
path,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Recursively validate each nested rule
|
|
521
|
+
for idx, nested_rule in enumerate(nested_rules):
|
|
522
|
+
nested_path = f"{path}.rules[{idx}]" if path else f"rules[{idx}]"
|
|
523
|
+
self._validate_rule_recursive(
|
|
524
|
+
rule_config=nested_rule,
|
|
525
|
+
result=result,
|
|
526
|
+
config=config,
|
|
527
|
+
depth=depth + 1,
|
|
528
|
+
path=nested_path,
|
|
529
|
+
visited_paths=visited_paths,
|
|
530
|
+
parent_chain=parent_chain,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _validate_simple_rule(
|
|
534
|
+
self,
|
|
535
|
+
rule_config: dict[str, Any],
|
|
536
|
+
rule_type: str,
|
|
537
|
+
result: RuleValidationResult,
|
|
538
|
+
path: str,
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Validate a simple (non-combinator) rule.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
rule_config: The rule configuration.
|
|
544
|
+
rule_type: The rule type.
|
|
545
|
+
result: Validation result to update.
|
|
546
|
+
path: Current JSON path.
|
|
547
|
+
"""
|
|
548
|
+
from .rules import RuleRegistry
|
|
549
|
+
|
|
550
|
+
# Check if rule type is registered
|
|
551
|
+
rule_class = RuleRegistry.get(rule_type)
|
|
552
|
+
if rule_class is None:
|
|
553
|
+
available_types = RuleRegistry.list_types()
|
|
554
|
+
result.add_error(
|
|
555
|
+
ValidationErrorType.UNKNOWN_RULE_TYPE,
|
|
556
|
+
f"Unknown rule type '{rule_type}'. Available types: {available_types}",
|
|
557
|
+
path,
|
|
558
|
+
{"rule_type": rule_type, "available_types": available_types},
|
|
559
|
+
)
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
# Get params (support both top-level and nested params format)
|
|
563
|
+
params = {}
|
|
564
|
+
nested_params = rule_config.get("params", {})
|
|
565
|
+
if isinstance(nested_params, dict):
|
|
566
|
+
params.update(nested_params)
|
|
567
|
+
|
|
568
|
+
# Also support top-level params (e.g., {"type": "severity", "min_severity": "critical"})
|
|
569
|
+
for key, value in rule_config.items():
|
|
570
|
+
if key not in ("type", "params", "rules", "rule"):
|
|
571
|
+
params[key] = value
|
|
572
|
+
|
|
573
|
+
# Validate required parameters
|
|
574
|
+
param_schema = rule_class.get_param_schema()
|
|
575
|
+
for param_name, param_spec in param_schema.items():
|
|
576
|
+
if param_spec.get("required", False) and param_name not in params:
|
|
577
|
+
result.add_error(
|
|
578
|
+
ValidationErrorType.MISSING_REQUIRED_FIELD,
|
|
579
|
+
f"Missing required parameter '{param_name}' for rule type '{rule_type}'",
|
|
580
|
+
f"{path}.params.{param_name}" if path else f"params.{param_name}",
|
|
581
|
+
{"rule_type": rule_type, "parameter": param_name},
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _check_reserved_fields(
|
|
585
|
+
self,
|
|
586
|
+
data: dict[str, Any],
|
|
587
|
+
result: RuleValidationResult,
|
|
588
|
+
config: RuleValidationConfig,
|
|
589
|
+
path: str,
|
|
590
|
+
) -> None:
|
|
591
|
+
"""Check for reserved field names in data.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
data: Dictionary to check.
|
|
595
|
+
result: Validation result to update.
|
|
596
|
+
config: Validation configuration.
|
|
597
|
+
path: Current JSON path.
|
|
598
|
+
"""
|
|
599
|
+
for key in data.keys():
|
|
600
|
+
if key.lower() in {rf.lower() for rf in config.reserved_field_names}:
|
|
601
|
+
result.add_error(
|
|
602
|
+
ValidationErrorType.RESERVED_FIELD_NAME,
|
|
603
|
+
f"Reserved field name '{key}' cannot be used",
|
|
604
|
+
f"{path}.{key}" if path else key,
|
|
605
|
+
{"field_name": key},
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def _generate_rule_id(self, rule_config: dict[str, Any], path: str) -> str:
|
|
609
|
+
"""Generate a unique identifier for a rule node.
|
|
610
|
+
|
|
611
|
+
For circular reference detection, we create an ID based on
|
|
612
|
+
the rule's structural properties.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
rule_config: The rule configuration.
|
|
616
|
+
path: The current path (used for position context).
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
A unique identifier string for this rule.
|
|
620
|
+
"""
|
|
621
|
+
rule_type = rule_config.get("type", "unknown")
|
|
622
|
+
|
|
623
|
+
# For simple rules, include type and key params
|
|
624
|
+
if rule_type not in self.COMBINATOR_TYPES:
|
|
625
|
+
params = rule_config.get("params", {})
|
|
626
|
+
# Create a deterministic string from key parameters
|
|
627
|
+
param_str = str(sorted(params.items())) if params else ""
|
|
628
|
+
return f"{rule_type}:{param_str}"
|
|
629
|
+
|
|
630
|
+
# For combinators, use type and path to distinguish branches
|
|
631
|
+
return f"{rule_type}@{path}"
|
|
632
|
+
|
|
633
|
+
def _format_cycle_path(self, parent_chain: list[str], current_id: str) -> str:
|
|
634
|
+
"""Format a cycle path for error reporting.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
parent_chain: List of parent rule IDs.
|
|
638
|
+
current_id: The current rule ID that creates the cycle.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
Formatted cycle path string (e.g., "A -> B -> C -> A").
|
|
642
|
+
"""
|
|
643
|
+
if current_id in parent_chain:
|
|
644
|
+
# Find where the cycle starts
|
|
645
|
+
cycle_start = parent_chain.index(current_id)
|
|
646
|
+
cycle_nodes = parent_chain[cycle_start:] + [current_id]
|
|
647
|
+
return " -> ".join(cycle_nodes)
|
|
648
|
+
return f"... -> {current_id}"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# Convenience function for quick validation
|
|
652
|
+
def validate_rule_config(
|
|
653
|
+
rule_config: dict[str, Any],
|
|
654
|
+
max_depth: int = 10,
|
|
655
|
+
max_rules_per_combinator: int = 50,
|
|
656
|
+
check_circular_refs: bool = True,
|
|
657
|
+
) -> RuleValidationResult:
|
|
658
|
+
"""Validate a rule configuration with custom settings.
|
|
659
|
+
|
|
660
|
+
This is a convenience function for one-off validations.
|
|
661
|
+
For repeated validations, create a RuleValidator instance.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
rule_config: The rule configuration to validate.
|
|
665
|
+
max_depth: Maximum nesting depth allowed.
|
|
666
|
+
max_rules_per_combinator: Maximum rules in a single combinator.
|
|
667
|
+
check_circular_refs: Whether to check for circular references.
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
RuleValidationResult with validation details.
|
|
671
|
+
"""
|
|
672
|
+
config = RuleValidationConfig(
|
|
673
|
+
max_depth=max_depth,
|
|
674
|
+
max_rules_per_combinator=max_rules_per_combinator,
|
|
675
|
+
check_circular_refs=check_circular_refs,
|
|
676
|
+
)
|
|
677
|
+
validator = RuleValidator(config)
|
|
678
|
+
return validator.validate(rule_config)
|