mcp-hangar 0.2.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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security event handler for the MCP Registry.
|
|
3
|
+
|
|
4
|
+
Provides dedicated security audit logging for:
|
|
5
|
+
- Authentication and authorization events
|
|
6
|
+
- Access control violations
|
|
7
|
+
- Rate limit violations
|
|
8
|
+
- Suspicious activity detection
|
|
9
|
+
- Input validation failures
|
|
10
|
+
- Command injection attempts
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from enum import Enum
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from ...domain.events import (
|
|
25
|
+
DomainEvent,
|
|
26
|
+
HealthCheckFailed,
|
|
27
|
+
ProviderDegraded,
|
|
28
|
+
ProviderStarted,
|
|
29
|
+
ProviderStopped,
|
|
30
|
+
ToolInvocationCompleted,
|
|
31
|
+
ToolInvocationFailed,
|
|
32
|
+
)
|
|
33
|
+
from ...logging_config import get_logger
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SecurityEventType(Enum):
|
|
39
|
+
"""Types of security events."""
|
|
40
|
+
|
|
41
|
+
# Access events
|
|
42
|
+
ACCESS_GRANTED = "access_granted"
|
|
43
|
+
ACCESS_DENIED = "access_denied"
|
|
44
|
+
|
|
45
|
+
# Rate limiting
|
|
46
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
47
|
+
RATE_LIMIT_WARNING = "rate_limit_warning"
|
|
48
|
+
|
|
49
|
+
# Validation
|
|
50
|
+
VALIDATION_FAILED = "validation_failed"
|
|
51
|
+
INJECTION_ATTEMPT = "injection_attempt"
|
|
52
|
+
|
|
53
|
+
# Provider security
|
|
54
|
+
PROVIDER_START_BLOCKED = "provider_start_blocked"
|
|
55
|
+
SUSPICIOUS_COMMAND = "suspicious_command"
|
|
56
|
+
UNAUTHORIZED_TOOL = "unauthorized_tool"
|
|
57
|
+
|
|
58
|
+
# Health and availability
|
|
59
|
+
REPEATED_FAILURES = "repeated_failures"
|
|
60
|
+
PROVIDER_COMPROMISE_SUSPECTED = "provider_compromise_suspected"
|
|
61
|
+
|
|
62
|
+
# Configuration
|
|
63
|
+
CONFIG_CHANGE = "config_change"
|
|
64
|
+
SENSITIVE_DATA_ACCESS = "sensitive_data_access"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SecuritySeverity(Enum):
|
|
68
|
+
"""Severity levels for security events."""
|
|
69
|
+
|
|
70
|
+
INFO = "info"
|
|
71
|
+
LOW = "low"
|
|
72
|
+
MEDIUM = "medium"
|
|
73
|
+
HIGH = "high"
|
|
74
|
+
CRITICAL = "critical"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class SecurityEvent:
|
|
79
|
+
"""Represents a security-related event."""
|
|
80
|
+
|
|
81
|
+
event_type: SecurityEventType
|
|
82
|
+
severity: SecuritySeverity
|
|
83
|
+
message: str
|
|
84
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
85
|
+
|
|
86
|
+
# Context
|
|
87
|
+
provider_id: Optional[str] = None
|
|
88
|
+
tool_name: Optional[str] = None
|
|
89
|
+
source_ip: Optional[str] = None
|
|
90
|
+
user_id: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
# Details
|
|
93
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
# Tracking
|
|
96
|
+
event_id: str = field(default_factory=lambda: "")
|
|
97
|
+
correlation_id: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
"""Generate event ID if not provided."""
|
|
101
|
+
if not self.event_id:
|
|
102
|
+
# Generate deterministic ID from content
|
|
103
|
+
content = f"{self.event_type.value}:{self.timestamp.isoformat()}:{self.message}"
|
|
104
|
+
self.event_id = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
107
|
+
"""Convert to dictionary for serialization."""
|
|
108
|
+
return {
|
|
109
|
+
"event_id": self.event_id,
|
|
110
|
+
"event_type": self.event_type.value,
|
|
111
|
+
"severity": self.severity.value,
|
|
112
|
+
"message": self.message,
|
|
113
|
+
"timestamp": self.timestamp.isoformat(),
|
|
114
|
+
"provider_id": self.provider_id,
|
|
115
|
+
"tool_name": self.tool_name,
|
|
116
|
+
"source_ip": self.source_ip,
|
|
117
|
+
"user_id": self.user_id,
|
|
118
|
+
"details": self.details,
|
|
119
|
+
"correlation_id": self.correlation_id,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def to_json(self) -> str:
|
|
123
|
+
"""Convert to JSON string."""
|
|
124
|
+
return json.dumps(self.to_dict())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SecurityEventSink(ABC):
|
|
128
|
+
"""Abstract interface for security event destinations."""
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def emit(self, event: SecurityEvent) -> None:
|
|
132
|
+
"""Emit a security event."""
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class LogSecuritySink(SecurityEventSink):
|
|
137
|
+
"""Security sink that writes to structured logs."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, logger_name: str = "security"):
|
|
140
|
+
self._logger = logging.getLogger(logger_name)
|
|
141
|
+
|
|
142
|
+
def emit(self, event: SecurityEvent) -> None:
|
|
143
|
+
"""Log the security event with appropriate level."""
|
|
144
|
+
log_data = {"security_event": event.to_dict()}
|
|
145
|
+
|
|
146
|
+
# Map severity to log level
|
|
147
|
+
if event.severity == SecuritySeverity.CRITICAL:
|
|
148
|
+
self._logger.critical(json.dumps(log_data))
|
|
149
|
+
elif event.severity == SecuritySeverity.HIGH:
|
|
150
|
+
self._logger.error(json.dumps(log_data))
|
|
151
|
+
elif event.severity == SecuritySeverity.MEDIUM:
|
|
152
|
+
self._logger.warning(json.dumps(log_data))
|
|
153
|
+
elif event.severity == SecuritySeverity.LOW:
|
|
154
|
+
self._logger.info(json.dumps(log_data))
|
|
155
|
+
else:
|
|
156
|
+
self._logger.debug(json.dumps(log_data))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class InMemorySecuritySink(SecurityEventSink):
|
|
160
|
+
"""In-memory security sink for testing and recent event queries."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, max_events: int = 10000):
|
|
163
|
+
self._events: List[SecurityEvent] = []
|
|
164
|
+
self._max_events = max_events
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
def emit(self, event: SecurityEvent) -> None:
|
|
168
|
+
"""Store the security event."""
|
|
169
|
+
with self._lock:
|
|
170
|
+
self._events.append(event)
|
|
171
|
+
if len(self._events) > self._max_events:
|
|
172
|
+
self._events = self._events[-self._max_events :]
|
|
173
|
+
|
|
174
|
+
def query(
|
|
175
|
+
self,
|
|
176
|
+
event_type: Optional[SecurityEventType] = None,
|
|
177
|
+
severity: Optional[SecuritySeverity] = None,
|
|
178
|
+
provider_id: Optional[str] = None,
|
|
179
|
+
since: Optional[datetime] = None,
|
|
180
|
+
limit: int = 100,
|
|
181
|
+
) -> List[SecurityEvent]:
|
|
182
|
+
"""Query stored security events."""
|
|
183
|
+
with self._lock:
|
|
184
|
+
results = []
|
|
185
|
+
for event in reversed(self._events):
|
|
186
|
+
if len(results) >= limit:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if event_type and event.event_type != event_type:
|
|
190
|
+
continue
|
|
191
|
+
if severity and event.severity != severity:
|
|
192
|
+
continue
|
|
193
|
+
if provider_id and event.provider_id != provider_id:
|
|
194
|
+
continue
|
|
195
|
+
if since and event.timestamp < since:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
results.append(event)
|
|
199
|
+
|
|
200
|
+
return results
|
|
201
|
+
|
|
202
|
+
def get_severity_counts(self) -> Dict[str, int]:
|
|
203
|
+
"""Get counts by severity level."""
|
|
204
|
+
with self._lock:
|
|
205
|
+
counts = {s.value: 0 for s in SecuritySeverity}
|
|
206
|
+
for event in self._events:
|
|
207
|
+
counts[event.severity.value] += 1
|
|
208
|
+
return counts
|
|
209
|
+
|
|
210
|
+
def clear(self) -> None:
|
|
211
|
+
"""Clear all stored events."""
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._events.clear()
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def count(self) -> int:
|
|
217
|
+
"""Get total event count."""
|
|
218
|
+
with self._lock:
|
|
219
|
+
return len(self._events)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CallbackSecuritySink(SecurityEventSink):
|
|
223
|
+
"""Security sink that calls a callback function."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, callback):
|
|
226
|
+
self._callback = callback
|
|
227
|
+
|
|
228
|
+
def emit(self, event: SecurityEvent) -> None:
|
|
229
|
+
"""Call the callback with the event."""
|
|
230
|
+
try:
|
|
231
|
+
self._callback(event)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f"Security callback failed: {e}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class CompositeSecuritySink(SecurityEventSink):
|
|
237
|
+
"""Security sink that emits to multiple sinks."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, sinks: List[SecurityEventSink]):
|
|
240
|
+
self._sinks = sinks
|
|
241
|
+
|
|
242
|
+
def emit(self, event: SecurityEvent) -> None:
|
|
243
|
+
"""Emit to all configured sinks."""
|
|
244
|
+
for sink in self._sinks:
|
|
245
|
+
try:
|
|
246
|
+
sink.emit(event)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Security sink {type(sink).__name__} failed: {e}")
|
|
249
|
+
|
|
250
|
+
def add_sink(self, sink: SecurityEventSink) -> None:
|
|
251
|
+
"""Add a sink."""
|
|
252
|
+
self._sinks.append(sink)
|
|
253
|
+
|
|
254
|
+
def remove_sink(self, sink: SecurityEventSink) -> None:
|
|
255
|
+
"""Remove a sink."""
|
|
256
|
+
if sink in self._sinks:
|
|
257
|
+
self._sinks.remove(sink)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SecurityEventHandler:
|
|
261
|
+
"""
|
|
262
|
+
Handler for domain events that detects and logs security-relevant activity.
|
|
263
|
+
|
|
264
|
+
Monitors for:
|
|
265
|
+
- Repeated failures (potential attacks)
|
|
266
|
+
- Unusual patterns
|
|
267
|
+
- Rate limit violations
|
|
268
|
+
- Validation failures
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
# Thresholds for anomaly detection
|
|
272
|
+
FAILURE_THRESHOLD = 5 # Failures before warning
|
|
273
|
+
CRITICAL_FAILURE_THRESHOLD = 10 # Failures before critical alert
|
|
274
|
+
TIME_WINDOW_S = 300 # 5 minute window for tracking
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
sink: Optional[SecurityEventSink] = None,
|
|
279
|
+
enable_anomaly_detection: bool = True,
|
|
280
|
+
):
|
|
281
|
+
"""
|
|
282
|
+
Initialize the security handler.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
sink: Where to emit security events (defaults to log sink)
|
|
286
|
+
enable_anomaly_detection: Whether to detect anomalies in event patterns
|
|
287
|
+
"""
|
|
288
|
+
self._sink = sink or LogSecuritySink()
|
|
289
|
+
self._enable_anomaly_detection = enable_anomaly_detection
|
|
290
|
+
|
|
291
|
+
# Tracking for anomaly detection
|
|
292
|
+
self._failure_counts: Dict[str, List[float]] = {} # provider_id -> timestamps
|
|
293
|
+
self._lock = threading.Lock()
|
|
294
|
+
|
|
295
|
+
def handle(self, event: DomainEvent) -> None:
|
|
296
|
+
"""
|
|
297
|
+
Handle a domain event, checking for security implications.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
event: The domain event to process
|
|
301
|
+
"""
|
|
302
|
+
# Dispatch to specific handlers
|
|
303
|
+
handlers = {
|
|
304
|
+
ProviderStarted: self._handle_provider_started,
|
|
305
|
+
ProviderStopped: self._handle_provider_stopped,
|
|
306
|
+
ProviderDegraded: self._handle_provider_degraded,
|
|
307
|
+
ToolInvocationCompleted: self._handle_tool_invocation_completed,
|
|
308
|
+
ToolInvocationFailed: self._handle_tool_invocation_failed,
|
|
309
|
+
HealthCheckFailed: self._handle_health_check_failed,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
handler = handlers.get(type(event))
|
|
313
|
+
if handler:
|
|
314
|
+
handler(event)
|
|
315
|
+
|
|
316
|
+
# Run anomaly detection
|
|
317
|
+
if self._enable_anomaly_detection:
|
|
318
|
+
self._check_anomalies(event)
|
|
319
|
+
|
|
320
|
+
def _handle_provider_started(self, event: ProviderStarted) -> None:
|
|
321
|
+
"""Handle provider start event."""
|
|
322
|
+
# Log provider starts for audit trail
|
|
323
|
+
self._emit(
|
|
324
|
+
SecurityEvent(
|
|
325
|
+
event_type=SecurityEventType.ACCESS_GRANTED,
|
|
326
|
+
severity=SecuritySeverity.INFO,
|
|
327
|
+
message=f"Provider started: {event.provider_id}",
|
|
328
|
+
provider_id=event.provider_id,
|
|
329
|
+
details={
|
|
330
|
+
"mode": event.mode,
|
|
331
|
+
"tools_count": event.tools_count,
|
|
332
|
+
"startup_duration_ms": event.startup_duration_ms,
|
|
333
|
+
},
|
|
334
|
+
correlation_id=event.event_id,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _handle_provider_stopped(self, event: ProviderStopped) -> None:
|
|
339
|
+
"""Handle provider stop event."""
|
|
340
|
+
# Clear failure tracking for this provider
|
|
341
|
+
with self._lock:
|
|
342
|
+
self._failure_counts.pop(event.provider_id, None)
|
|
343
|
+
|
|
344
|
+
def _handle_provider_degraded(self, event: ProviderDegraded) -> None:
|
|
345
|
+
"""Handle provider degradation event."""
|
|
346
|
+
severity = SecuritySeverity.MEDIUM
|
|
347
|
+
if event.consecutive_failures >= self.CRITICAL_FAILURE_THRESHOLD:
|
|
348
|
+
severity = SecuritySeverity.HIGH
|
|
349
|
+
|
|
350
|
+
self._emit(
|
|
351
|
+
SecurityEvent(
|
|
352
|
+
event_type=SecurityEventType.REPEATED_FAILURES,
|
|
353
|
+
severity=severity,
|
|
354
|
+
message=f"Provider degraded after {event.consecutive_failures} consecutive failures",
|
|
355
|
+
provider_id=event.provider_id,
|
|
356
|
+
details={
|
|
357
|
+
"consecutive_failures": event.consecutive_failures,
|
|
358
|
+
"total_failures": event.total_failures,
|
|
359
|
+
"reason": event.reason,
|
|
360
|
+
},
|
|
361
|
+
correlation_id=event.event_id,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _handle_tool_invocation_completed(self, event: ToolInvocationCompleted) -> None:
|
|
366
|
+
"""Handle successful tool invocation."""
|
|
367
|
+
# Only log if unusually slow (potential DoS or resource exhaustion)
|
|
368
|
+
if event.duration_ms > 10000: # > 10 seconds
|
|
369
|
+
self._emit(
|
|
370
|
+
SecurityEvent(
|
|
371
|
+
event_type=SecurityEventType.ACCESS_GRANTED,
|
|
372
|
+
severity=SecuritySeverity.LOW,
|
|
373
|
+
message=f"Slow tool invocation: {event.tool_name}",
|
|
374
|
+
provider_id=event.provider_id,
|
|
375
|
+
tool_name=event.tool_name,
|
|
376
|
+
details={
|
|
377
|
+
"duration_ms": event.duration_ms,
|
|
378
|
+
},
|
|
379
|
+
correlation_id=event.event_id,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _handle_tool_invocation_failed(self, event: ToolInvocationFailed) -> None:
|
|
384
|
+
"""Handle failed tool invocation."""
|
|
385
|
+
self._record_failure(event.provider_id)
|
|
386
|
+
|
|
387
|
+
self._emit(
|
|
388
|
+
SecurityEvent(
|
|
389
|
+
event_type=SecurityEventType.ACCESS_DENIED,
|
|
390
|
+
severity=SecuritySeverity.LOW,
|
|
391
|
+
message=f"Tool invocation failed: {event.tool_name}",
|
|
392
|
+
provider_id=event.provider_id,
|
|
393
|
+
tool_name=event.tool_name,
|
|
394
|
+
details={
|
|
395
|
+
"error": event.error_message,
|
|
396
|
+
},
|
|
397
|
+
correlation_id=event.event_id,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _handle_health_check_failed(self, event: HealthCheckFailed) -> None:
|
|
402
|
+
"""Handle health check failure."""
|
|
403
|
+
self._record_failure(event.provider_id)
|
|
404
|
+
|
|
405
|
+
if event.consecutive_failures >= self.FAILURE_THRESHOLD:
|
|
406
|
+
self._emit(
|
|
407
|
+
SecurityEvent(
|
|
408
|
+
event_type=SecurityEventType.REPEATED_FAILURES,
|
|
409
|
+
severity=SecuritySeverity.MEDIUM,
|
|
410
|
+
message="Multiple health check failures for provider",
|
|
411
|
+
provider_id=event.provider_id,
|
|
412
|
+
details={
|
|
413
|
+
"consecutive_failures": event.consecutive_failures,
|
|
414
|
+
"error": event.error_message,
|
|
415
|
+
},
|
|
416
|
+
correlation_id=event.event_id,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def _record_failure(self, provider_id: str) -> None:
|
|
421
|
+
"""Record a failure for anomaly detection."""
|
|
422
|
+
now = time.time()
|
|
423
|
+
with self._lock:
|
|
424
|
+
if provider_id not in self._failure_counts:
|
|
425
|
+
self._failure_counts[provider_id] = []
|
|
426
|
+
|
|
427
|
+
# Add current failure
|
|
428
|
+
self._failure_counts[provider_id].append(now)
|
|
429
|
+
|
|
430
|
+
# Clean old entries
|
|
431
|
+
cutoff = now - self.TIME_WINDOW_S
|
|
432
|
+
self._failure_counts[provider_id] = [t for t in self._failure_counts[provider_id] if t > cutoff]
|
|
433
|
+
|
|
434
|
+
def _check_anomalies(self, event: DomainEvent) -> None:
|
|
435
|
+
"""Check for anomalous patterns across events."""
|
|
436
|
+
provider_id = getattr(event, "provider_id", None)
|
|
437
|
+
if not provider_id:
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
with self._lock:
|
|
441
|
+
failures = self._failure_counts.get(provider_id, [])
|
|
442
|
+
|
|
443
|
+
if len(failures) >= self.CRITICAL_FAILURE_THRESHOLD:
|
|
444
|
+
self._emit(
|
|
445
|
+
SecurityEvent(
|
|
446
|
+
event_type=SecurityEventType.PROVIDER_COMPROMISE_SUSPECTED,
|
|
447
|
+
severity=SecuritySeverity.HIGH,
|
|
448
|
+
message="High failure rate detected for provider (possible attack or compromise)",
|
|
449
|
+
provider_id=provider_id,
|
|
450
|
+
details={
|
|
451
|
+
"failures_in_window": len(failures),
|
|
452
|
+
"window_seconds": self.TIME_WINDOW_S,
|
|
453
|
+
},
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _emit(self, event: SecurityEvent) -> None:
|
|
458
|
+
"""Emit a security event to the sink."""
|
|
459
|
+
try:
|
|
460
|
+
self._sink.emit(event)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.error(f"Failed to emit security event: {e}")
|
|
463
|
+
|
|
464
|
+
# --- Public API for direct security event emission ---
|
|
465
|
+
|
|
466
|
+
def log_rate_limit_exceeded(
|
|
467
|
+
self,
|
|
468
|
+
provider_id: Optional[str] = None,
|
|
469
|
+
limit: int = 0,
|
|
470
|
+
window_seconds: int = 0,
|
|
471
|
+
source_ip: Optional[str] = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Log a rate limit violation."""
|
|
474
|
+
self._emit(
|
|
475
|
+
SecurityEvent(
|
|
476
|
+
event_type=SecurityEventType.RATE_LIMIT_EXCEEDED,
|
|
477
|
+
severity=SecuritySeverity.MEDIUM,
|
|
478
|
+
message="Rate limit exceeded",
|
|
479
|
+
provider_id=provider_id,
|
|
480
|
+
source_ip=source_ip,
|
|
481
|
+
details={
|
|
482
|
+
"limit": limit,
|
|
483
|
+
"window_seconds": window_seconds,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def log_validation_failed(
|
|
489
|
+
self,
|
|
490
|
+
field: str,
|
|
491
|
+
message: str,
|
|
492
|
+
provider_id: Optional[str] = None,
|
|
493
|
+
value: Optional[str] = None,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Log a validation failure."""
|
|
496
|
+
# Determine severity based on field
|
|
497
|
+
severity = SecuritySeverity.LOW
|
|
498
|
+
if field in ("command", "image"):
|
|
499
|
+
severity = SecuritySeverity.MEDIUM
|
|
500
|
+
|
|
501
|
+
details = {"field": field}
|
|
502
|
+
if value:
|
|
503
|
+
# Truncate value for safety
|
|
504
|
+
details["value"] = value[:50] if len(value) > 50 else value
|
|
505
|
+
|
|
506
|
+
self._emit(
|
|
507
|
+
SecurityEvent(
|
|
508
|
+
event_type=SecurityEventType.VALIDATION_FAILED,
|
|
509
|
+
severity=severity,
|
|
510
|
+
message=f"Validation failed: {message}",
|
|
511
|
+
provider_id=provider_id,
|
|
512
|
+
details=details,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def log_injection_attempt(
|
|
517
|
+
self,
|
|
518
|
+
field: str,
|
|
519
|
+
pattern: str,
|
|
520
|
+
provider_id: Optional[str] = None,
|
|
521
|
+
source_ip: Optional[str] = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Log a potential injection attempt."""
|
|
524
|
+
self._emit(
|
|
525
|
+
SecurityEvent(
|
|
526
|
+
event_type=SecurityEventType.INJECTION_ATTEMPT,
|
|
527
|
+
severity=SecuritySeverity.HIGH,
|
|
528
|
+
message=f"Potential injection attempt detected in {field}",
|
|
529
|
+
provider_id=provider_id,
|
|
530
|
+
source_ip=source_ip,
|
|
531
|
+
details={
|
|
532
|
+
"field": field,
|
|
533
|
+
"pattern_detected": pattern,
|
|
534
|
+
},
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def log_suspicious_command(
|
|
539
|
+
self,
|
|
540
|
+
command: List[str],
|
|
541
|
+
provider_id: Optional[str] = None,
|
|
542
|
+
reason: str = "",
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Log a suspicious command execution attempt."""
|
|
545
|
+
# Sanitize command for logging (don't log full values)
|
|
546
|
+
safe_command = [c[:20] + "..." if len(c) > 20 else c for c in command[:5]]
|
|
547
|
+
|
|
548
|
+
self._emit(
|
|
549
|
+
SecurityEvent(
|
|
550
|
+
event_type=SecurityEventType.SUSPICIOUS_COMMAND,
|
|
551
|
+
severity=SecuritySeverity.HIGH,
|
|
552
|
+
message=f"Suspicious command blocked: {reason}",
|
|
553
|
+
provider_id=provider_id,
|
|
554
|
+
details={
|
|
555
|
+
"command_preview": safe_command,
|
|
556
|
+
"reason": reason,
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def log_config_change(
|
|
562
|
+
self,
|
|
563
|
+
change_type: str,
|
|
564
|
+
provider_id: Optional[str] = None,
|
|
565
|
+
user_id: Optional[str] = None,
|
|
566
|
+
details: Optional[Dict[str, Any]] = None,
|
|
567
|
+
) -> None:
|
|
568
|
+
"""Log a configuration change."""
|
|
569
|
+
self._emit(
|
|
570
|
+
SecurityEvent(
|
|
571
|
+
event_type=SecurityEventType.CONFIG_CHANGE,
|
|
572
|
+
severity=SecuritySeverity.INFO,
|
|
573
|
+
message=f"Configuration changed: {change_type}",
|
|
574
|
+
provider_id=provider_id,
|
|
575
|
+
user_id=user_id,
|
|
576
|
+
details=details or {},
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def sink(self) -> SecurityEventSink:
|
|
582
|
+
"""Get the security event sink."""
|
|
583
|
+
return self._sink
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --- Global security handler instance ---
|
|
587
|
+
|
|
588
|
+
_security_handler: Optional[SecurityEventHandler] = None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def get_security_handler(
|
|
592
|
+
sink: Optional[SecurityEventSink] = None,
|
|
593
|
+
) -> SecurityEventHandler:
|
|
594
|
+
"""Get or create the global security handler instance."""
|
|
595
|
+
global _security_handler
|
|
596
|
+
if _security_handler is None:
|
|
597
|
+
_security_handler = SecurityEventHandler(sink=sink)
|
|
598
|
+
return _security_handler
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def reset_security_handler() -> None:
|
|
602
|
+
"""Reset the global security handler (for testing)."""
|
|
603
|
+
global _security_handler
|
|
604
|
+
_security_handler = None
|