pactown 0.1.4__py3-none-any.whl → 0.1.47__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.
- pactown/__init__.py +178 -4
- pactown/cli.py +539 -37
- pactown/config.py +12 -11
- pactown/deploy/__init__.py +17 -3
- pactown/deploy/base.py +35 -33
- pactown/deploy/compose.py +59 -58
- pactown/deploy/docker.py +40 -41
- pactown/deploy/kubernetes.py +43 -42
- pactown/deploy/podman.py +55 -56
- pactown/deploy/quadlet.py +1021 -0
- pactown/deploy/quadlet_api.py +533 -0
- pactown/deploy/quadlet_shell.py +557 -0
- pactown/events.py +1066 -0
- pactown/fast_start.py +514 -0
- pactown/generator.py +31 -30
- pactown/llm.py +450 -0
- pactown/markpact_blocks.py +50 -0
- pactown/network.py +59 -38
- pactown/orchestrator.py +90 -93
- pactown/parallel.py +40 -40
- pactown/platform.py +146 -0
- pactown/registry/__init__.py +1 -1
- pactown/registry/client.py +45 -46
- pactown/registry/models.py +25 -25
- pactown/registry/server.py +24 -24
- pactown/resolver.py +30 -30
- pactown/runner_api.py +458 -0
- pactown/sandbox_manager.py +480 -79
- pactown/security.py +682 -0
- pactown/service_runner.py +1201 -0
- pactown/user_isolation.py +458 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/METADATA +65 -9
- pactown-0.1.47.dist-info/RECORD +36 -0
- pactown-0.1.47.dist-info/entry_points.txt +5 -0
- pactown-0.1.4.dist-info/RECORD +0 -24
- pactown-0.1.4.dist-info/entry_points.txt +0 -3
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/WHEEL +0 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/licenses/LICENSE +0 -0
pactown/security.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security module for pactown.
|
|
3
|
+
|
|
4
|
+
Provides rate limiting, resource protection, user profiles with service limits,
|
|
5
|
+
throttling under load, and anomaly logging for admin monitoring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, UTC
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import Lock
|
|
17
|
+
from typing import Callable, Dict, List, Optional, Any
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Configure logging for anomalies
|
|
22
|
+
logging.basicConfig(level=logging.INFO)
|
|
23
|
+
anomaly_logger = logging.getLogger("pactown.security.anomaly")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AnomalyType(str, Enum):
|
|
27
|
+
"""Types of security anomalies."""
|
|
28
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
29
|
+
CONCURRENT_LIMIT_EXCEEDED = "concurrent_limit_exceeded"
|
|
30
|
+
MEMORY_LIMIT_EXCEEDED = "memory_limit_exceeded"
|
|
31
|
+
CPU_LIMIT_EXCEEDED = "cpu_limit_exceeded"
|
|
32
|
+
SERVER_OVERLOADED = "server_overloaded"
|
|
33
|
+
SUSPICIOUS_PATTERN = "suspicious_pattern"
|
|
34
|
+
UNAUTHORIZED_ACCESS = "unauthorized_access"
|
|
35
|
+
RESOURCE_EXHAUSTION = "resource_exhaustion"
|
|
36
|
+
RAPID_RESTART = "rapid_restart"
|
|
37
|
+
PORT_SCAN_DETECTED = "port_scan_detected"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UserTier(str, Enum):
|
|
41
|
+
"""User tier levels with different resource limits."""
|
|
42
|
+
FREE = "free"
|
|
43
|
+
BASIC = "basic"
|
|
44
|
+
PRO = "pro"
|
|
45
|
+
ENTERPRISE = "enterprise"
|
|
46
|
+
ADMIN = "admin"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class UserProfile:
|
|
51
|
+
"""User profile with resource limits and permissions."""
|
|
52
|
+
user_id: str
|
|
53
|
+
tier: UserTier = UserTier.FREE
|
|
54
|
+
max_concurrent_services: int = 2
|
|
55
|
+
max_memory_mb: int = 512
|
|
56
|
+
max_cpu_percent: int = 50
|
|
57
|
+
max_requests_per_minute: int = 30
|
|
58
|
+
max_services_per_hour: int = 10
|
|
59
|
+
allowed_ports: Optional[List[int]] = None # None = any port in range
|
|
60
|
+
blocked: bool = False
|
|
61
|
+
reason: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_tier(cls, user_id: str, tier: UserTier) -> "UserProfile":
|
|
65
|
+
"""Create profile with tier-based defaults."""
|
|
66
|
+
tier_limits = {
|
|
67
|
+
UserTier.FREE: {
|
|
68
|
+
"max_concurrent_services": 2,
|
|
69
|
+
"max_memory_mb": 256,
|
|
70
|
+
"max_cpu_percent": 25,
|
|
71
|
+
"max_requests_per_minute": 20,
|
|
72
|
+
"max_services_per_hour": 5,
|
|
73
|
+
},
|
|
74
|
+
UserTier.BASIC: {
|
|
75
|
+
"max_concurrent_services": 5,
|
|
76
|
+
"max_memory_mb": 512,
|
|
77
|
+
"max_cpu_percent": 50,
|
|
78
|
+
"max_requests_per_minute": 60,
|
|
79
|
+
"max_services_per_hour": 20,
|
|
80
|
+
},
|
|
81
|
+
UserTier.PRO: {
|
|
82
|
+
"max_concurrent_services": 10,
|
|
83
|
+
"max_memory_mb": 2048,
|
|
84
|
+
"max_cpu_percent": 80,
|
|
85
|
+
"max_requests_per_minute": 120,
|
|
86
|
+
"max_services_per_hour": 50,
|
|
87
|
+
},
|
|
88
|
+
UserTier.ENTERPRISE: {
|
|
89
|
+
"max_concurrent_services": 50,
|
|
90
|
+
"max_memory_mb": 8192,
|
|
91
|
+
"max_cpu_percent": 100,
|
|
92
|
+
"max_requests_per_minute": 500,
|
|
93
|
+
"max_services_per_hour": 200,
|
|
94
|
+
},
|
|
95
|
+
UserTier.ADMIN: {
|
|
96
|
+
"max_concurrent_services": 100,
|
|
97
|
+
"max_memory_mb": 16384,
|
|
98
|
+
"max_cpu_percent": 100,
|
|
99
|
+
"max_requests_per_minute": 1000,
|
|
100
|
+
"max_services_per_hour": 1000,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
limits = tier_limits.get(tier, tier_limits[UserTier.FREE])
|
|
104
|
+
return cls(user_id=user_id, tier=tier, **limits)
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict:
|
|
107
|
+
"""Convert to dictionary for API/JSON."""
|
|
108
|
+
return {
|
|
109
|
+
"user_id": self.user_id,
|
|
110
|
+
"tier": self.tier.value,
|
|
111
|
+
"max_concurrent_services": self.max_concurrent_services,
|
|
112
|
+
"max_memory_mb": self.max_memory_mb,
|
|
113
|
+
"max_cpu_percent": self.max_cpu_percent,
|
|
114
|
+
"max_requests_per_minute": self.max_requests_per_minute,
|
|
115
|
+
"max_services_per_hour": self.max_services_per_hour,
|
|
116
|
+
"allowed_ports": self.allowed_ports,
|
|
117
|
+
"blocked": self.blocked,
|
|
118
|
+
"reason": self.reason,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_dict(cls, data: dict) -> "UserProfile":
|
|
123
|
+
"""Create from dictionary."""
|
|
124
|
+
tier = UserTier(data.get("tier", "free"))
|
|
125
|
+
return cls(
|
|
126
|
+
user_id=data.get("user_id", "unknown"),
|
|
127
|
+
tier=tier,
|
|
128
|
+
max_concurrent_services=data.get("max_concurrent_services", 2),
|
|
129
|
+
max_memory_mb=data.get("max_memory_mb", 512),
|
|
130
|
+
max_cpu_percent=data.get("max_cpu_percent", 50),
|
|
131
|
+
max_requests_per_minute=data.get("max_requests_per_minute", 30),
|
|
132
|
+
max_services_per_hour=data.get("max_services_per_hour", 10),
|
|
133
|
+
allowed_ports=data.get("allowed_ports"),
|
|
134
|
+
blocked=data.get("blocked", False),
|
|
135
|
+
reason=data.get("reason"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class AnomalyEvent:
|
|
141
|
+
"""Record of a security anomaly."""
|
|
142
|
+
timestamp: datetime
|
|
143
|
+
anomaly_type: AnomalyType
|
|
144
|
+
user_id: Optional[str]
|
|
145
|
+
service_id: Optional[str]
|
|
146
|
+
details: str
|
|
147
|
+
severity: str # "low", "medium", "high", "critical"
|
|
148
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict:
|
|
151
|
+
return {
|
|
152
|
+
"timestamp": self.timestamp.isoformat(),
|
|
153
|
+
"anomaly_type": self.anomaly_type.value,
|
|
154
|
+
"user_id": self.user_id,
|
|
155
|
+
"service_id": self.service_id,
|
|
156
|
+
"details": self.details,
|
|
157
|
+
"severity": self.severity,
|
|
158
|
+
"metadata": self.metadata,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def to_log_line(self) -> str:
|
|
162
|
+
return (
|
|
163
|
+
f"[{self.severity.upper()}] {self.anomaly_type.value} | "
|
|
164
|
+
f"user={self.user_id} service={self.service_id} | {self.details}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AnomalyLogger:
|
|
169
|
+
"""Logs security anomalies for admin review."""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
log_path: Optional[Path] = None,
|
|
174
|
+
max_events: int = 10000,
|
|
175
|
+
on_anomaly: Optional[Callable[[AnomalyEvent], None]] = None,
|
|
176
|
+
):
|
|
177
|
+
self.log_path = log_path or Path("/tmp/pactown-anomalies.jsonl")
|
|
178
|
+
self.max_events = max_events
|
|
179
|
+
self.on_anomaly = on_anomaly
|
|
180
|
+
self._events: List[AnomalyEvent] = []
|
|
181
|
+
self._lock = Lock()
|
|
182
|
+
|
|
183
|
+
def log(
|
|
184
|
+
self,
|
|
185
|
+
anomaly_type: AnomalyType,
|
|
186
|
+
details: str,
|
|
187
|
+
user_id: Optional[str] = None,
|
|
188
|
+
service_id: Optional[str] = None,
|
|
189
|
+
severity: str = "medium",
|
|
190
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
191
|
+
) -> AnomalyEvent:
|
|
192
|
+
"""Log an anomaly event."""
|
|
193
|
+
event = AnomalyEvent(
|
|
194
|
+
timestamp=datetime.now(UTC),
|
|
195
|
+
anomaly_type=anomaly_type,
|
|
196
|
+
user_id=user_id,
|
|
197
|
+
service_id=service_id,
|
|
198
|
+
details=details,
|
|
199
|
+
severity=severity,
|
|
200
|
+
metadata=metadata or {},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._events.append(event)
|
|
205
|
+
if len(self._events) > self.max_events:
|
|
206
|
+
self._events = self._events[-self.max_events:]
|
|
207
|
+
|
|
208
|
+
# Log to file
|
|
209
|
+
try:
|
|
210
|
+
with open(self.log_path, "a") as f:
|
|
211
|
+
f.write(json.dumps(event.to_dict()) + "\n")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
anomaly_logger.error(f"Failed to write anomaly log: {e}")
|
|
214
|
+
|
|
215
|
+
# Log to Python logger
|
|
216
|
+
log_level = {
|
|
217
|
+
"low": logging.DEBUG,
|
|
218
|
+
"medium": logging.WARNING,
|
|
219
|
+
"high": logging.ERROR,
|
|
220
|
+
"critical": logging.CRITICAL,
|
|
221
|
+
}.get(severity, logging.WARNING)
|
|
222
|
+
anomaly_logger.log(log_level, event.to_log_line())
|
|
223
|
+
|
|
224
|
+
# Call callback if provided
|
|
225
|
+
if self.on_anomaly:
|
|
226
|
+
try:
|
|
227
|
+
self.on_anomaly(event)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
return event
|
|
232
|
+
|
|
233
|
+
def get_recent(self, count: int = 100) -> List[AnomalyEvent]:
|
|
234
|
+
"""Get recent anomaly events."""
|
|
235
|
+
with self._lock:
|
|
236
|
+
return self._events[-count:]
|
|
237
|
+
|
|
238
|
+
def get_by_user(self, user_id: str, count: int = 100) -> List[AnomalyEvent]:
|
|
239
|
+
"""Get anomalies for a specific user."""
|
|
240
|
+
with self._lock:
|
|
241
|
+
return [e for e in self._events if e.user_id == user_id][-count:]
|
|
242
|
+
|
|
243
|
+
def get_by_type(self, anomaly_type: AnomalyType, count: int = 100) -> List[AnomalyEvent]:
|
|
244
|
+
"""Get anomalies of a specific type."""
|
|
245
|
+
with self._lock:
|
|
246
|
+
return [e for e in self._events if e.anomaly_type == anomaly_type][-count:]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class RateLimiter:
|
|
250
|
+
"""Token bucket rate limiter."""
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
requests_per_minute: int = 60,
|
|
255
|
+
burst_size: int = 10,
|
|
256
|
+
):
|
|
257
|
+
self.requests_per_minute = requests_per_minute
|
|
258
|
+
self.burst_size = burst_size
|
|
259
|
+
self._buckets: Dict[str, Dict] = {}
|
|
260
|
+
self._lock = Lock()
|
|
261
|
+
|
|
262
|
+
def _get_bucket(self, key: str) -> Dict:
|
|
263
|
+
"""Get or create a token bucket for a key."""
|
|
264
|
+
now = time.time()
|
|
265
|
+
with self._lock:
|
|
266
|
+
if key not in self._buckets:
|
|
267
|
+
self._buckets[key] = {
|
|
268
|
+
"tokens": self.burst_size,
|
|
269
|
+
"last_update": now,
|
|
270
|
+
}
|
|
271
|
+
bucket = self._buckets[key]
|
|
272
|
+
|
|
273
|
+
# Refill tokens based on time elapsed
|
|
274
|
+
elapsed = now - bucket["last_update"]
|
|
275
|
+
refill = elapsed * (self.requests_per_minute / 60.0)
|
|
276
|
+
bucket["tokens"] = min(self.burst_size, bucket["tokens"] + refill)
|
|
277
|
+
bucket["last_update"] = now
|
|
278
|
+
|
|
279
|
+
return bucket
|
|
280
|
+
|
|
281
|
+
def check(self, key: str) -> bool:
|
|
282
|
+
"""Check if request is allowed (doesn't consume token)."""
|
|
283
|
+
bucket = self._get_bucket(key)
|
|
284
|
+
return bucket["tokens"] >= 1.0
|
|
285
|
+
|
|
286
|
+
def consume(self, key: str) -> bool:
|
|
287
|
+
"""Try to consume a token. Returns True if allowed."""
|
|
288
|
+
bucket = self._get_bucket(key)
|
|
289
|
+
with self._lock:
|
|
290
|
+
if bucket["tokens"] >= 1.0:
|
|
291
|
+
bucket["tokens"] -= 1.0
|
|
292
|
+
return True
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
def get_wait_time(self, key: str) -> float:
|
|
296
|
+
"""Get seconds to wait before next request is allowed."""
|
|
297
|
+
bucket = self._get_bucket(key)
|
|
298
|
+
if bucket["tokens"] >= 1.0:
|
|
299
|
+
return 0.0
|
|
300
|
+
tokens_needed = 1.0 - bucket["tokens"]
|
|
301
|
+
return tokens_needed / (self.requests_per_minute / 60.0)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ResourceMonitor:
|
|
305
|
+
"""Monitors system resources and detects overload."""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
cpu_threshold: float = 80.0,
|
|
310
|
+
memory_threshold: float = 85.0,
|
|
311
|
+
check_interval: float = 5.0,
|
|
312
|
+
):
|
|
313
|
+
self.cpu_threshold = cpu_threshold
|
|
314
|
+
self.memory_threshold = memory_threshold
|
|
315
|
+
self.check_interval = check_interval
|
|
316
|
+
self._last_check = 0.0
|
|
317
|
+
self._is_overloaded = False
|
|
318
|
+
self._lock = Lock()
|
|
319
|
+
|
|
320
|
+
def _get_cpu_percent(self) -> float:
|
|
321
|
+
"""Get current CPU usage percentage."""
|
|
322
|
+
try:
|
|
323
|
+
with open("/proc/stat", "r") as f:
|
|
324
|
+
line = f.readline()
|
|
325
|
+
parts = line.split()[1:5]
|
|
326
|
+
user, nice, system, idle = map(int, parts)
|
|
327
|
+
total = user + nice + system + idle
|
|
328
|
+
used = user + nice + system
|
|
329
|
+
return (used / total) * 100 if total > 0 else 0.0
|
|
330
|
+
except:
|
|
331
|
+
return 0.0
|
|
332
|
+
|
|
333
|
+
def _get_memory_percent(self) -> float:
|
|
334
|
+
"""Get current memory usage percentage."""
|
|
335
|
+
try:
|
|
336
|
+
with open("/proc/meminfo", "r") as f:
|
|
337
|
+
lines = f.readlines()
|
|
338
|
+
mem_info = {}
|
|
339
|
+
for line in lines[:5]:
|
|
340
|
+
parts = line.split()
|
|
341
|
+
mem_info[parts[0].rstrip(":")] = int(parts[1])
|
|
342
|
+
total = mem_info.get("MemTotal", 1)
|
|
343
|
+
available = mem_info.get("MemAvailable", mem_info.get("MemFree", 0))
|
|
344
|
+
used = total - available
|
|
345
|
+
return (used / total) * 100 if total > 0 else 0.0
|
|
346
|
+
except:
|
|
347
|
+
return 0.0
|
|
348
|
+
|
|
349
|
+
def check_overload(self) -> tuple[bool, Dict[str, float]]:
|
|
350
|
+
"""Check if system is overloaded. Returns (is_overloaded, metrics)."""
|
|
351
|
+
now = time.time()
|
|
352
|
+
|
|
353
|
+
with self._lock:
|
|
354
|
+
if now - self._last_check < self.check_interval:
|
|
355
|
+
return self._is_overloaded, {}
|
|
356
|
+
|
|
357
|
+
self._last_check = now
|
|
358
|
+
|
|
359
|
+
cpu = self._get_cpu_percent()
|
|
360
|
+
memory = self._get_memory_percent()
|
|
361
|
+
|
|
362
|
+
self._is_overloaded = (
|
|
363
|
+
cpu > self.cpu_threshold or
|
|
364
|
+
memory > self.memory_threshold
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return self._is_overloaded, {
|
|
368
|
+
"cpu_percent": cpu,
|
|
369
|
+
"memory_percent": memory,
|
|
370
|
+
"cpu_threshold": self.cpu_threshold,
|
|
371
|
+
"memory_threshold": self.memory_threshold,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
def get_throttle_delay(self) -> float:
|
|
375
|
+
"""Get delay in seconds based on current load."""
|
|
376
|
+
is_overloaded, metrics = self.check_overload()
|
|
377
|
+
if not is_overloaded:
|
|
378
|
+
return 0.0
|
|
379
|
+
|
|
380
|
+
# Calculate delay based on how much we're over threshold
|
|
381
|
+
cpu_over = max(0, metrics.get("cpu_percent", 0) - self.cpu_threshold)
|
|
382
|
+
mem_over = max(0, metrics.get("memory_percent", 0) - self.memory_threshold)
|
|
383
|
+
|
|
384
|
+
# Delay scales with overload: 0.5s base + up to 5s based on severity
|
|
385
|
+
max_over = max(cpu_over, mem_over)
|
|
386
|
+
return min(5.0, 0.5 + (max_over / 20.0) * 4.5)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass
|
|
390
|
+
class SecurityCheckResult:
|
|
391
|
+
"""Result of a security check."""
|
|
392
|
+
allowed: bool
|
|
393
|
+
reason: Optional[str] = None
|
|
394
|
+
delay_seconds: float = 0.0
|
|
395
|
+
anomaly: Optional[AnomalyEvent] = None
|
|
396
|
+
|
|
397
|
+
def to_dict(self) -> dict:
|
|
398
|
+
return {
|
|
399
|
+
"allowed": self.allowed,
|
|
400
|
+
"reason": self.reason,
|
|
401
|
+
"delay_seconds": self.delay_seconds,
|
|
402
|
+
"anomaly": self.anomaly.to_dict() if self.anomaly else None,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class SecurityPolicy:
|
|
407
|
+
"""
|
|
408
|
+
Main security policy enforcer for pactown.
|
|
409
|
+
|
|
410
|
+
Combines rate limiting, resource monitoring, user profiles,
|
|
411
|
+
and anomaly logging into a unified security layer.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def __init__(
|
|
415
|
+
self,
|
|
416
|
+
anomaly_log_path: Optional[Path] = None,
|
|
417
|
+
default_rate_limit: int = 60,
|
|
418
|
+
cpu_threshold: float = 80.0,
|
|
419
|
+
memory_threshold: float = 85.0,
|
|
420
|
+
on_anomaly: Optional[Callable[[AnomalyEvent], None]] = None,
|
|
421
|
+
):
|
|
422
|
+
self.anomaly_logger = AnomalyLogger(
|
|
423
|
+
log_path=anomaly_log_path,
|
|
424
|
+
on_anomaly=on_anomaly,
|
|
425
|
+
)
|
|
426
|
+
self.rate_limiter = RateLimiter(requests_per_minute=default_rate_limit)
|
|
427
|
+
self.resource_monitor = ResourceMonitor(
|
|
428
|
+
cpu_threshold=cpu_threshold,
|
|
429
|
+
memory_threshold=memory_threshold,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
self._user_profiles: Dict[str, UserProfile] = {}
|
|
433
|
+
self._user_services: Dict[str, List[str]] = {} # user_id -> [service_ids]
|
|
434
|
+
self._service_starts: Dict[str, List[float]] = {} # user_id -> [timestamps]
|
|
435
|
+
self._lock = Lock()
|
|
436
|
+
|
|
437
|
+
def set_user_profile(self, profile: UserProfile) -> None:
|
|
438
|
+
"""Set or update a user profile."""
|
|
439
|
+
with self._lock:
|
|
440
|
+
self._user_profiles[profile.user_id] = profile
|
|
441
|
+
|
|
442
|
+
def get_user_profile(self, user_id: str) -> UserProfile:
|
|
443
|
+
"""Get user profile, creating default if not exists."""
|
|
444
|
+
with self._lock:
|
|
445
|
+
if user_id not in self._user_profiles:
|
|
446
|
+
self._user_profiles[user_id] = UserProfile.from_tier(user_id, UserTier.FREE)
|
|
447
|
+
return self._user_profiles[user_id]
|
|
448
|
+
|
|
449
|
+
def register_service(self, user_id: str, service_id: str) -> None:
|
|
450
|
+
"""Register a running service for a user."""
|
|
451
|
+
with self._lock:
|
|
452
|
+
if user_id not in self._user_services:
|
|
453
|
+
self._user_services[user_id] = []
|
|
454
|
+
if service_id not in self._user_services[user_id]:
|
|
455
|
+
self._user_services[user_id].append(service_id)
|
|
456
|
+
|
|
457
|
+
# Track service start time
|
|
458
|
+
if user_id not in self._service_starts:
|
|
459
|
+
self._service_starts[user_id] = []
|
|
460
|
+
self._service_starts[user_id].append(time.time())
|
|
461
|
+
|
|
462
|
+
# Clean old entries (older than 1 hour)
|
|
463
|
+
cutoff = time.time() - 3600
|
|
464
|
+
self._service_starts[user_id] = [
|
|
465
|
+
t for t in self._service_starts[user_id] if t > cutoff
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
def unregister_service(self, user_id: str, service_id: str) -> None:
|
|
469
|
+
"""Unregister a stopped service."""
|
|
470
|
+
with self._lock:
|
|
471
|
+
if user_id in self._user_services:
|
|
472
|
+
if service_id in self._user_services[user_id]:
|
|
473
|
+
self._user_services[user_id].remove(service_id)
|
|
474
|
+
|
|
475
|
+
def get_user_service_count(self, user_id: str) -> int:
|
|
476
|
+
"""Get number of running services for a user."""
|
|
477
|
+
with self._lock:
|
|
478
|
+
return len(self._user_services.get(user_id, []))
|
|
479
|
+
|
|
480
|
+
def get_services_started_last_hour(self, user_id: str) -> int:
|
|
481
|
+
"""Get number of services started in the last hour."""
|
|
482
|
+
with self._lock:
|
|
483
|
+
cutoff = time.time() - 3600
|
|
484
|
+
starts = self._service_starts.get(user_id, [])
|
|
485
|
+
return len([t for t in starts if t > cutoff])
|
|
486
|
+
|
|
487
|
+
async def check_can_start_service(
|
|
488
|
+
self,
|
|
489
|
+
user_id: str,
|
|
490
|
+
service_id: str,
|
|
491
|
+
port: Optional[int] = None,
|
|
492
|
+
) -> SecurityCheckResult:
|
|
493
|
+
"""
|
|
494
|
+
Check if a user can start a new service.
|
|
495
|
+
|
|
496
|
+
Returns SecurityCheckResult with allowed status and any required delay.
|
|
497
|
+
"""
|
|
498
|
+
profile = self.get_user_profile(user_id)
|
|
499
|
+
|
|
500
|
+
# Check if user is blocked
|
|
501
|
+
if profile.blocked:
|
|
502
|
+
anomaly = self.anomaly_logger.log(
|
|
503
|
+
AnomalyType.UNAUTHORIZED_ACCESS,
|
|
504
|
+
f"Blocked user {user_id} attempted to start service",
|
|
505
|
+
user_id=user_id,
|
|
506
|
+
service_id=service_id,
|
|
507
|
+
severity="high",
|
|
508
|
+
)
|
|
509
|
+
return SecurityCheckResult(
|
|
510
|
+
allowed=False,
|
|
511
|
+
reason=f"User blocked: {profile.reason or 'No reason provided'}",
|
|
512
|
+
anomaly=anomaly,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Check rate limit
|
|
516
|
+
rate_key = f"user:{user_id}:start"
|
|
517
|
+
if not self.rate_limiter.check(rate_key):
|
|
518
|
+
wait_time = self.rate_limiter.get_wait_time(rate_key)
|
|
519
|
+
anomaly = self.anomaly_logger.log(
|
|
520
|
+
AnomalyType.RATE_LIMIT_EXCEEDED,
|
|
521
|
+
f"User {user_id} exceeded rate limit for service starts",
|
|
522
|
+
user_id=user_id,
|
|
523
|
+
service_id=service_id,
|
|
524
|
+
severity="medium",
|
|
525
|
+
metadata={"wait_time": wait_time},
|
|
526
|
+
)
|
|
527
|
+
return SecurityCheckResult(
|
|
528
|
+
allowed=False,
|
|
529
|
+
reason=f"Rate limit exceeded. Wait {wait_time:.1f}s",
|
|
530
|
+
delay_seconds=wait_time,
|
|
531
|
+
anomaly=anomaly,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Check concurrent service limit
|
|
535
|
+
current_count = self.get_user_service_count(user_id)
|
|
536
|
+
if current_count >= profile.max_concurrent_services:
|
|
537
|
+
anomaly = self.anomaly_logger.log(
|
|
538
|
+
AnomalyType.CONCURRENT_LIMIT_EXCEEDED,
|
|
539
|
+
f"User {user_id} at max concurrent services ({current_count}/{profile.max_concurrent_services})",
|
|
540
|
+
user_id=user_id,
|
|
541
|
+
service_id=service_id,
|
|
542
|
+
severity="medium",
|
|
543
|
+
metadata={
|
|
544
|
+
"current": current_count,
|
|
545
|
+
"max": profile.max_concurrent_services,
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
return SecurityCheckResult(
|
|
549
|
+
allowed=False,
|
|
550
|
+
reason=f"Max concurrent services reached ({current_count}/{profile.max_concurrent_services}). Stop a service first.",
|
|
551
|
+
anomaly=anomaly,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Check hourly service limit
|
|
555
|
+
hourly_count = self.get_services_started_last_hour(user_id)
|
|
556
|
+
if hourly_count >= profile.max_services_per_hour:
|
|
557
|
+
anomaly = self.anomaly_logger.log(
|
|
558
|
+
AnomalyType.RATE_LIMIT_EXCEEDED,
|
|
559
|
+
f"User {user_id} exceeded hourly service limit ({hourly_count}/{profile.max_services_per_hour})",
|
|
560
|
+
user_id=user_id,
|
|
561
|
+
service_id=service_id,
|
|
562
|
+
severity="medium",
|
|
563
|
+
)
|
|
564
|
+
return SecurityCheckResult(
|
|
565
|
+
allowed=False,
|
|
566
|
+
reason=f"Hourly service limit reached ({hourly_count}/{profile.max_services_per_hour}). Try again later.",
|
|
567
|
+
anomaly=anomaly,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Check port restrictions
|
|
571
|
+
if port and profile.allowed_ports:
|
|
572
|
+
if port not in profile.allowed_ports:
|
|
573
|
+
anomaly = self.anomaly_logger.log(
|
|
574
|
+
AnomalyType.UNAUTHORIZED_ACCESS,
|
|
575
|
+
f"User {user_id} attempted to use restricted port {port}",
|
|
576
|
+
user_id=user_id,
|
|
577
|
+
service_id=service_id,
|
|
578
|
+
severity="high",
|
|
579
|
+
metadata={"port": port, "allowed": profile.allowed_ports},
|
|
580
|
+
)
|
|
581
|
+
return SecurityCheckResult(
|
|
582
|
+
allowed=False,
|
|
583
|
+
reason=f"Port {port} not allowed for your account",
|
|
584
|
+
anomaly=anomaly,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Check system resources
|
|
588
|
+
is_overloaded, metrics = self.resource_monitor.check_overload()
|
|
589
|
+
if is_overloaded:
|
|
590
|
+
delay = self.resource_monitor.get_throttle_delay()
|
|
591
|
+
anomaly = self.anomaly_logger.log(
|
|
592
|
+
AnomalyType.SERVER_OVERLOADED,
|
|
593
|
+
f"Server overloaded, throttling user {user_id}",
|
|
594
|
+
user_id=user_id,
|
|
595
|
+
service_id=service_id,
|
|
596
|
+
severity="medium",
|
|
597
|
+
metadata=metrics,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# For free tier, deny during overload
|
|
601
|
+
if profile.tier == UserTier.FREE:
|
|
602
|
+
return SecurityCheckResult(
|
|
603
|
+
allowed=False,
|
|
604
|
+
reason="Server is currently overloaded. Please try again later.",
|
|
605
|
+
delay_seconds=delay,
|
|
606
|
+
anomaly=anomaly,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# For paid tiers, allow but with delay
|
|
610
|
+
return SecurityCheckResult(
|
|
611
|
+
allowed=True,
|
|
612
|
+
reason=f"Server under load, request delayed by {delay:.1f}s",
|
|
613
|
+
delay_seconds=delay,
|
|
614
|
+
anomaly=anomaly,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Check for rapid restart pattern (potential abuse)
|
|
618
|
+
starts = self._service_starts.get(user_id, [])
|
|
619
|
+
recent_starts = [t for t in starts if time.time() - t < 60] # Last minute
|
|
620
|
+
if len(recent_starts) >= 5:
|
|
621
|
+
anomaly = self.anomaly_logger.log(
|
|
622
|
+
AnomalyType.RAPID_RESTART,
|
|
623
|
+
f"User {user_id} showing rapid restart pattern ({len(recent_starts)} in 60s)",
|
|
624
|
+
user_id=user_id,
|
|
625
|
+
service_id=service_id,
|
|
626
|
+
severity="medium",
|
|
627
|
+
metadata={"restarts_last_minute": len(recent_starts)},
|
|
628
|
+
)
|
|
629
|
+
# Allow but log for monitoring
|
|
630
|
+
|
|
631
|
+
# Consume rate limit token
|
|
632
|
+
self.rate_limiter.consume(rate_key)
|
|
633
|
+
|
|
634
|
+
return SecurityCheckResult(allowed=True)
|
|
635
|
+
|
|
636
|
+
def get_anomaly_summary(self, hours: int = 24) -> Dict[str, Any]:
|
|
637
|
+
"""Get summary of anomalies for admin dashboard."""
|
|
638
|
+
cutoff = datetime.now(UTC).timestamp() - (hours * 3600)
|
|
639
|
+
recent = [
|
|
640
|
+
e for e in self.anomaly_logger.get_recent(1000)
|
|
641
|
+
if e.timestamp.timestamp() > cutoff
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
by_type: Dict[str, int] = {}
|
|
645
|
+
by_severity: Dict[str, int] = {}
|
|
646
|
+
by_user: Dict[str, int] = {}
|
|
647
|
+
|
|
648
|
+
for event in recent:
|
|
649
|
+
by_type[event.anomaly_type.value] = by_type.get(event.anomaly_type.value, 0) + 1
|
|
650
|
+
by_severity[event.severity] = by_severity.get(event.severity, 0) + 1
|
|
651
|
+
if event.user_id:
|
|
652
|
+
by_user[event.user_id] = by_user.get(event.user_id, 0) + 1
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
"period_hours": hours,
|
|
656
|
+
"total_anomalies": len(recent),
|
|
657
|
+
"by_type": by_type,
|
|
658
|
+
"by_severity": by_severity,
|
|
659
|
+
"top_users": dict(sorted(by_user.items(), key=lambda x: -x[1])[:10]),
|
|
660
|
+
"recent_critical": [
|
|
661
|
+
e.to_dict() for e in recent
|
|
662
|
+
if e.severity == "critical"
|
|
663
|
+
][-10:],
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# Global default policy instance
|
|
668
|
+
_default_policy: Optional[SecurityPolicy] = None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def get_security_policy() -> SecurityPolicy:
|
|
672
|
+
"""Get the global security policy instance."""
|
|
673
|
+
global _default_policy
|
|
674
|
+
if _default_policy is None:
|
|
675
|
+
_default_policy = SecurityPolicy()
|
|
676
|
+
return _default_policy
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def set_security_policy(policy: SecurityPolicy) -> None:
|
|
680
|
+
"""Set the global security policy instance."""
|
|
681
|
+
global _default_policy
|
|
682
|
+
_default_policy = policy
|