clonebox 1.1.13__py3-none-any.whl → 1.1.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of clonebox might be problematic. Click here for more details.
- clonebox/audit.py +452 -0
- clonebox/cli.py +966 -10
- clonebox/cloner.py +221 -135
- clonebox/orchestrator.py +568 -0
- clonebox/plugins/__init__.py +24 -0
- clonebox/plugins/base.py +319 -0
- clonebox/plugins/manager.py +523 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +113 -41
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/METADATA +5 -1
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/RECORD +16 -10
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/WHEEL +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/top_level.txt +0 -0
clonebox/audit.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit logging for CloneBox operations.
|
|
3
|
+
Records all significant actions for compliance and debugging.
|
|
4
|
+
"""
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Any, List, Generator
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import hashlib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditEventType(Enum):
|
|
18
|
+
"""Types of auditable events."""
|
|
19
|
+
# VM Operations
|
|
20
|
+
VM_CREATE = "vm.create"
|
|
21
|
+
VM_START = "vm.start"
|
|
22
|
+
VM_STOP = "vm.stop"
|
|
23
|
+
VM_DELETE = "vm.delete"
|
|
24
|
+
VM_RESTART = "vm.restart"
|
|
25
|
+
VM_SNAPSHOT_CREATE = "vm.snapshot.create"
|
|
26
|
+
VM_SNAPSHOT_RESTORE = "vm.snapshot.restore"
|
|
27
|
+
VM_SNAPSHOT_DELETE = "vm.snapshot.delete"
|
|
28
|
+
VM_EXPORT = "vm.export"
|
|
29
|
+
VM_IMPORT = "vm.import"
|
|
30
|
+
|
|
31
|
+
# Configuration
|
|
32
|
+
CONFIG_CREATE = "config.create"
|
|
33
|
+
CONFIG_MODIFY = "config.modify"
|
|
34
|
+
CONFIG_DELETE = "config.delete"
|
|
35
|
+
CONFIG_LOAD = "config.load"
|
|
36
|
+
|
|
37
|
+
# Secrets
|
|
38
|
+
SECRETS_ACCESS = "secrets.access"
|
|
39
|
+
SECRETS_MODIFY = "secrets.modify"
|
|
40
|
+
|
|
41
|
+
# Authentication
|
|
42
|
+
AUTH_SSH_KEY_GENERATED = "auth.ssh_key.generated"
|
|
43
|
+
AUTH_PASSWORD_GENERATED = "auth.password.generated"
|
|
44
|
+
|
|
45
|
+
# Health
|
|
46
|
+
HEALTH_CHECK_RUN = "health.check.run"
|
|
47
|
+
HEALTH_CHECK_FAILED = "health.check.failed"
|
|
48
|
+
HEALTH_CHECK_PASSED = "health.check.passed"
|
|
49
|
+
|
|
50
|
+
# Repair
|
|
51
|
+
REPAIR_TRIGGERED = "repair.triggered"
|
|
52
|
+
REPAIR_COMPLETED = "repair.completed"
|
|
53
|
+
REPAIR_FAILED = "repair.failed"
|
|
54
|
+
|
|
55
|
+
# Container Operations
|
|
56
|
+
CONTAINER_UP = "container.up"
|
|
57
|
+
CONTAINER_STOP = "container.stop"
|
|
58
|
+
CONTAINER_RM = "container.rm"
|
|
59
|
+
|
|
60
|
+
# P2P Operations
|
|
61
|
+
P2P_EXPORT = "p2p.export"
|
|
62
|
+
P2P_IMPORT = "p2p.import"
|
|
63
|
+
P2P_SYNC_KEY = "p2p.sync_key"
|
|
64
|
+
|
|
65
|
+
# System
|
|
66
|
+
SYSTEM_ERROR = "system.error"
|
|
67
|
+
SYSTEM_WARNING = "system.warning"
|
|
68
|
+
SYSTEM_STARTUP = "system.startup"
|
|
69
|
+
SYSTEM_SHUTDOWN = "system.shutdown"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AuditOutcome(Enum):
|
|
73
|
+
"""Outcome of an audited operation."""
|
|
74
|
+
SUCCESS = "success"
|
|
75
|
+
FAILURE = "failure"
|
|
76
|
+
PARTIAL = "partial"
|
|
77
|
+
DENIED = "denied"
|
|
78
|
+
SKIPPED = "skipped"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AuditEvent:
|
|
83
|
+
"""A single audit event."""
|
|
84
|
+
event_type: AuditEventType
|
|
85
|
+
timestamp: datetime
|
|
86
|
+
outcome: AuditOutcome
|
|
87
|
+
|
|
88
|
+
# Actor information
|
|
89
|
+
user: str
|
|
90
|
+
hostname: str
|
|
91
|
+
pid: int
|
|
92
|
+
|
|
93
|
+
# Target information
|
|
94
|
+
target_type: Optional[str] = None # "vm", "config", "snapshot", "container"
|
|
95
|
+
target_name: Optional[str] = None
|
|
96
|
+
|
|
97
|
+
# Details
|
|
98
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
error_message: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
# Correlation
|
|
102
|
+
correlation_id: Optional[str] = None
|
|
103
|
+
parent_event_id: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
# Computed
|
|
106
|
+
event_id: str = field(default_factory=lambda: "")
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
if not self.event_id:
|
|
110
|
+
content = f"{self.timestamp.isoformat()}{self.event_type.value}{self.user}{self.pid}"
|
|
111
|
+
self.event_id = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
114
|
+
"""Convert to dictionary for serialization."""
|
|
115
|
+
return {
|
|
116
|
+
"event_id": self.event_id,
|
|
117
|
+
"event_type": self.event_type.value,
|
|
118
|
+
"timestamp": self.timestamp.isoformat(),
|
|
119
|
+
"outcome": self.outcome.value,
|
|
120
|
+
"actor": {
|
|
121
|
+
"user": self.user,
|
|
122
|
+
"hostname": self.hostname,
|
|
123
|
+
"pid": self.pid,
|
|
124
|
+
},
|
|
125
|
+
"target": {
|
|
126
|
+
"type": self.target_type,
|
|
127
|
+
"name": self.target_name,
|
|
128
|
+
} if self.target_type else None,
|
|
129
|
+
"details": self.details,
|
|
130
|
+
"error_message": self.error_message,
|
|
131
|
+
"correlation_id": self.correlation_id,
|
|
132
|
+
"parent_event_id": self.parent_event_id,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def to_json(self) -> str:
|
|
136
|
+
"""Convert to JSON string."""
|
|
137
|
+
return json.dumps(self.to_dict(), default=str)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
|
|
141
|
+
"""Create from dictionary."""
|
|
142
|
+
actor = data.get("actor", {})
|
|
143
|
+
target = data.get("target", {}) or {}
|
|
144
|
+
return cls(
|
|
145
|
+
event_id=data.get("event_id", ""),
|
|
146
|
+
event_type=AuditEventType(data["event_type"]),
|
|
147
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
148
|
+
outcome=AuditOutcome(data["outcome"]),
|
|
149
|
+
user=actor.get("user", "unknown"),
|
|
150
|
+
hostname=actor.get("hostname", "unknown"),
|
|
151
|
+
pid=actor.get("pid", 0),
|
|
152
|
+
target_type=target.get("type"),
|
|
153
|
+
target_name=target.get("name"),
|
|
154
|
+
details=data.get("details", {}),
|
|
155
|
+
error_message=data.get("error_message"),
|
|
156
|
+
correlation_id=data.get("correlation_id"),
|
|
157
|
+
parent_event_id=data.get("parent_event_id"),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class AuditContext:
|
|
163
|
+
"""Context for an audited operation."""
|
|
164
|
+
_logger: "AuditLogger"
|
|
165
|
+
_event_type: AuditEventType
|
|
166
|
+
_target_type: Optional[str]
|
|
167
|
+
_target_name: Optional[str]
|
|
168
|
+
_details: Dict[str, Any] = field(default_factory=dict)
|
|
169
|
+
_outcome: AuditOutcome = AuditOutcome.SUCCESS
|
|
170
|
+
_error: Optional[str] = None
|
|
171
|
+
|
|
172
|
+
def add_detail(self, key: str, value: Any) -> None:
|
|
173
|
+
"""Add a detail to the audit event."""
|
|
174
|
+
self._details[key] = value
|
|
175
|
+
|
|
176
|
+
def set_outcome(self, outcome: AuditOutcome) -> None:
|
|
177
|
+
"""Set the outcome (overrides automatic detection)."""
|
|
178
|
+
self._outcome = outcome
|
|
179
|
+
|
|
180
|
+
def set_error(self, error: str) -> None:
|
|
181
|
+
"""Set error message."""
|
|
182
|
+
self._error = error
|
|
183
|
+
self._outcome = AuditOutcome.FAILURE
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class AuditLogger:
|
|
187
|
+
"""
|
|
188
|
+
Audit logger that writes events to file and/or external systems.
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
audit = AuditLogger()
|
|
192
|
+
|
|
193
|
+
with audit.operation(AuditEventType.VM_CREATE, target_type="vm", target_name="my-vm") as ctx:
|
|
194
|
+
ctx.add_detail("disk_size_gb", 30)
|
|
195
|
+
# do stuff
|
|
196
|
+
|
|
197
|
+
# Or manually
|
|
198
|
+
audit.log(AuditEventType.VM_START, outcome=AuditOutcome.SUCCESS, ...)
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
log_path: Optional[Path] = None,
|
|
204
|
+
enabled: bool = True,
|
|
205
|
+
console_echo: bool = False,
|
|
206
|
+
):
|
|
207
|
+
self.enabled = enabled
|
|
208
|
+
self.console_echo = console_echo
|
|
209
|
+
self._lock = threading.Lock()
|
|
210
|
+
self._correlation_id: Optional[str] = None
|
|
211
|
+
|
|
212
|
+
# Determine log path
|
|
213
|
+
if log_path:
|
|
214
|
+
self.log_path = log_path
|
|
215
|
+
else:
|
|
216
|
+
# Use user-local path by default
|
|
217
|
+
local_share = Path.home() / ".local" / "share" / "clonebox"
|
|
218
|
+
self.log_path = local_share / "audit.log"
|
|
219
|
+
|
|
220
|
+
# Get actor info once
|
|
221
|
+
self._user = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
|
|
222
|
+
try:
|
|
223
|
+
self._hostname = os.uname().nodename
|
|
224
|
+
except AttributeError:
|
|
225
|
+
import socket
|
|
226
|
+
self._hostname = socket.gethostname()
|
|
227
|
+
self._pid = os.getpid()
|
|
228
|
+
|
|
229
|
+
# Ensure log directory exists
|
|
230
|
+
if self.enabled:
|
|
231
|
+
try:
|
|
232
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
except (PermissionError, OSError):
|
|
234
|
+
# If we can't create log directory, disable audit logging
|
|
235
|
+
self.enabled = False
|
|
236
|
+
|
|
237
|
+
def log(
|
|
238
|
+
self,
|
|
239
|
+
event_type: AuditEventType,
|
|
240
|
+
outcome: AuditOutcome,
|
|
241
|
+
target_type: Optional[str] = None,
|
|
242
|
+
target_name: Optional[str] = None,
|
|
243
|
+
details: Optional[Dict[str, Any]] = None,
|
|
244
|
+
error_message: Optional[str] = None,
|
|
245
|
+
) -> AuditEvent:
|
|
246
|
+
"""Log an audit event."""
|
|
247
|
+
event = AuditEvent(
|
|
248
|
+
event_type=event_type,
|
|
249
|
+
timestamp=datetime.now(),
|
|
250
|
+
outcome=outcome,
|
|
251
|
+
user=self._user,
|
|
252
|
+
hostname=self._hostname,
|
|
253
|
+
pid=self._pid,
|
|
254
|
+
target_type=target_type,
|
|
255
|
+
target_name=target_name,
|
|
256
|
+
details=details or {},
|
|
257
|
+
error_message=error_message,
|
|
258
|
+
correlation_id=self._correlation_id,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if self.enabled:
|
|
262
|
+
self._write_event(event)
|
|
263
|
+
|
|
264
|
+
return event
|
|
265
|
+
|
|
266
|
+
def _write_event(self, event: AuditEvent) -> None:
|
|
267
|
+
"""Write event to log file."""
|
|
268
|
+
with self._lock:
|
|
269
|
+
try:
|
|
270
|
+
with open(self.log_path, "a") as f:
|
|
271
|
+
f.write(event.to_json() + "\n")
|
|
272
|
+
except Exception as e:
|
|
273
|
+
import sys
|
|
274
|
+
print(f"Audit log write failed: {e}", file=sys.stderr)
|
|
275
|
+
print(event.to_json(), file=sys.stderr)
|
|
276
|
+
|
|
277
|
+
if self.console_echo:
|
|
278
|
+
print(f"[AUDIT] {event.event_type.value}: {event.outcome.value}")
|
|
279
|
+
|
|
280
|
+
def set_correlation_id(self, correlation_id: str) -> None:
|
|
281
|
+
"""Set correlation ID for subsequent events."""
|
|
282
|
+
self._correlation_id = correlation_id
|
|
283
|
+
|
|
284
|
+
def clear_correlation_id(self) -> None:
|
|
285
|
+
"""Clear correlation ID."""
|
|
286
|
+
self._correlation_id = None
|
|
287
|
+
|
|
288
|
+
@contextmanager
|
|
289
|
+
def operation(
|
|
290
|
+
self,
|
|
291
|
+
event_type: AuditEventType,
|
|
292
|
+
target_type: Optional[str] = None,
|
|
293
|
+
target_name: Optional[str] = None,
|
|
294
|
+
) -> Generator[AuditContext, None, None]:
|
|
295
|
+
"""
|
|
296
|
+
Context manager for auditing an operation.
|
|
297
|
+
|
|
298
|
+
Usage:
|
|
299
|
+
with audit.operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
|
|
300
|
+
ctx.add_detail("config_path", "/path/to/config")
|
|
301
|
+
do_operation()
|
|
302
|
+
"""
|
|
303
|
+
ctx = AuditContext(
|
|
304
|
+
_logger=self,
|
|
305
|
+
_event_type=event_type,
|
|
306
|
+
_target_type=target_type,
|
|
307
|
+
_target_name=target_name,
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
yield ctx
|
|
311
|
+
if ctx._outcome == AuditOutcome.SUCCESS:
|
|
312
|
+
pass # Keep success
|
|
313
|
+
except Exception as e:
|
|
314
|
+
ctx._outcome = AuditOutcome.FAILURE
|
|
315
|
+
ctx._error = str(e)
|
|
316
|
+
raise
|
|
317
|
+
finally:
|
|
318
|
+
self.log(
|
|
319
|
+
event_type=event_type,
|
|
320
|
+
outcome=ctx._outcome,
|
|
321
|
+
target_type=target_type,
|
|
322
|
+
target_name=target_name,
|
|
323
|
+
details=ctx._details,
|
|
324
|
+
error_message=ctx._error,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class AuditQuery:
|
|
329
|
+
"""Query audit logs."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, log_path: Optional[Path] = None):
|
|
332
|
+
if log_path:
|
|
333
|
+
self.log_path = log_path
|
|
334
|
+
else:
|
|
335
|
+
self.log_path = Path.home() / ".local" / "share" / "clonebox" / "audit.log"
|
|
336
|
+
|
|
337
|
+
def query(
|
|
338
|
+
self,
|
|
339
|
+
event_type: Optional[AuditEventType] = None,
|
|
340
|
+
target_name: Optional[str] = None,
|
|
341
|
+
user: Optional[str] = None,
|
|
342
|
+
start_time: Optional[datetime] = None,
|
|
343
|
+
end_time: Optional[datetime] = None,
|
|
344
|
+
outcome: Optional[AuditOutcome] = None,
|
|
345
|
+
limit: int = 100,
|
|
346
|
+
) -> List[AuditEvent]:
|
|
347
|
+
"""Query audit events with filters."""
|
|
348
|
+
results: List[AuditEvent] = []
|
|
349
|
+
|
|
350
|
+
if not self.log_path.exists():
|
|
351
|
+
return results
|
|
352
|
+
|
|
353
|
+
with open(self.log_path) as f:
|
|
354
|
+
for line in f:
|
|
355
|
+
if len(results) >= limit:
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
data = json.loads(line)
|
|
360
|
+
|
|
361
|
+
# Apply filters
|
|
362
|
+
if event_type and data.get("event_type") != event_type.value:
|
|
363
|
+
continue
|
|
364
|
+
if target_name:
|
|
365
|
+
target = data.get("target") or {}
|
|
366
|
+
if target.get("name") != target_name:
|
|
367
|
+
continue
|
|
368
|
+
if user:
|
|
369
|
+
actor = data.get("actor") or {}
|
|
370
|
+
if actor.get("user") != user:
|
|
371
|
+
continue
|
|
372
|
+
if outcome and data.get("outcome") != outcome.value:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
event_time = datetime.fromisoformat(data["timestamp"])
|
|
376
|
+
if start_time and event_time < start_time:
|
|
377
|
+
continue
|
|
378
|
+
if end_time and event_time > end_time:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
event = AuditEvent.from_dict(data)
|
|
382
|
+
results.append(event)
|
|
383
|
+
|
|
384
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
return results
|
|
388
|
+
|
|
389
|
+
def get_recent(self, count: int = 20) -> List[AuditEvent]:
|
|
390
|
+
"""Get most recent audit events."""
|
|
391
|
+
all_events = self.query(limit=10000)
|
|
392
|
+
return all_events[-count:] if len(all_events) > count else all_events
|
|
393
|
+
|
|
394
|
+
def get_by_target(self, target_name: str, limit: int = 50) -> List[AuditEvent]:
|
|
395
|
+
"""Get events for a specific target."""
|
|
396
|
+
return self.query(target_name=target_name, limit=limit)
|
|
397
|
+
|
|
398
|
+
def get_failures(self, limit: int = 50) -> List[AuditEvent]:
|
|
399
|
+
"""Get failed events."""
|
|
400
|
+
return self.query(outcome=AuditOutcome.FAILURE, limit=limit)
|
|
401
|
+
|
|
402
|
+
def get_by_correlation(self, correlation_id: str) -> List[AuditEvent]:
|
|
403
|
+
"""Get events by correlation ID."""
|
|
404
|
+
results: List[AuditEvent] = []
|
|
405
|
+
|
|
406
|
+
if not self.log_path.exists():
|
|
407
|
+
return results
|
|
408
|
+
|
|
409
|
+
with open(self.log_path) as f:
|
|
410
|
+
for line in f:
|
|
411
|
+
try:
|
|
412
|
+
data = json.loads(line)
|
|
413
|
+
if data.get("correlation_id") == correlation_id:
|
|
414
|
+
results.append(AuditEvent.from_dict(data))
|
|
415
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
return results
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# Global audit logger
|
|
422
|
+
_audit_logger: Optional[AuditLogger] = None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def get_audit_logger() -> AuditLogger:
|
|
426
|
+
"""Get the global audit logger."""
|
|
427
|
+
global _audit_logger
|
|
428
|
+
if _audit_logger is None:
|
|
429
|
+
_audit_logger = AuditLogger()
|
|
430
|
+
return _audit_logger
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def set_audit_logger(logger: AuditLogger) -> None:
|
|
434
|
+
"""Set the global audit logger (useful for testing)."""
|
|
435
|
+
global _audit_logger
|
|
436
|
+
_audit_logger = logger
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def audit_operation(
|
|
440
|
+
event_type: AuditEventType,
|
|
441
|
+
target_type: Optional[str] = None,
|
|
442
|
+
target_name: Optional[str] = None,
|
|
443
|
+
) -> Generator[AuditContext, None, None]:
|
|
444
|
+
"""
|
|
445
|
+
Convenience function for auditing an operation.
|
|
446
|
+
|
|
447
|
+
Usage:
|
|
448
|
+
with audit_operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
|
|
449
|
+
ctx.add_detail("config_path", "/path/to/config")
|
|
450
|
+
do_operation()
|
|
451
|
+
"""
|
|
452
|
+
return get_audit_logger().operation(event_type, target_type, target_name)
|