clonebox 1.1.17__py3-none-any.whl → 1.1.19__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.
- clonebox/backends/libvirt_backend.py +3 -1
- clonebox/cli.py +591 -544
- clonebox/cloner.py +498 -422
- clonebox/health/probes.py +14 -0
- clonebox/policies/__init__.py +13 -0
- clonebox/policies/engine.py +112 -0
- clonebox/policies/models.py +55 -0
- clonebox/policies/validators.py +26 -0
- clonebox/validator.py +228 -51
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/METADATA +1 -1
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/RECORD +15 -11
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/WHEEL +0 -0
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/top_level.txt +0 -0
clonebox/health/probes.py
CHANGED
|
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
|
+
from clonebox.policies import PolicyEngine, PolicyViolationError
|
|
11
12
|
from .models import HealthCheckResult, HealthStatus, ProbeConfig
|
|
12
13
|
|
|
13
14
|
try:
|
|
@@ -57,6 +58,19 @@ class HTTPProbe(HealthProbe):
|
|
|
57
58
|
|
|
58
59
|
start = time.time()
|
|
59
60
|
try:
|
|
61
|
+
policy = PolicyEngine.load_effective()
|
|
62
|
+
if policy is not None:
|
|
63
|
+
try:
|
|
64
|
+
policy.assert_url_allowed(config.url)
|
|
65
|
+
except PolicyViolationError as e:
|
|
66
|
+
duration_ms = (time.time() - start) * 1000
|
|
67
|
+
return self._create_result(
|
|
68
|
+
config,
|
|
69
|
+
HealthStatus.UNHEALTHY,
|
|
70
|
+
duration_ms,
|
|
71
|
+
error=str(e),
|
|
72
|
+
)
|
|
73
|
+
|
|
60
74
|
req = urllib.request.Request(
|
|
61
75
|
config.url,
|
|
62
76
|
method=config.method,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .engine import PolicyEngine, PolicyValidationError, PolicyViolationError
|
|
2
|
+
from .models import PolicyFile, PolicySet, NetworkPolicy, OperationsPolicy, ResourcesPolicy
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"PolicyEngine",
|
|
6
|
+
"PolicyValidationError",
|
|
7
|
+
"PolicyViolationError",
|
|
8
|
+
"PolicyFile",
|
|
9
|
+
"PolicySet",
|
|
10
|
+
"NetworkPolicy",
|
|
11
|
+
"OperationsPolicy",
|
|
12
|
+
"ResourcesPolicy",
|
|
13
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .models import PolicyFile
|
|
11
|
+
from .validators import extract_hostname, is_host_allowed
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolicyValidationError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyViolationError(PermissionError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_PROJECT_POLICY_FILES = (".clonebox-policy.yaml", ".clonebox-policy.yml")
|
|
23
|
+
DEFAULT_GLOBAL_POLICY_FILE = Path.home() / ".clonebox.d" / "policy.yaml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PolicyEngine:
|
|
28
|
+
policy: PolicyFile
|
|
29
|
+
source: Path
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, path: Path) -> "PolicyEngine":
|
|
33
|
+
try:
|
|
34
|
+
raw = yaml.safe_load(path.read_text())
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise PolicyValidationError(f"Failed to read policy file {path}: {e}")
|
|
37
|
+
|
|
38
|
+
if not isinstance(raw, dict):
|
|
39
|
+
raise PolicyValidationError("Policy file must be a YAML mapping")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
policy = PolicyFile.model_validate(raw)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise PolicyValidationError(str(e))
|
|
45
|
+
|
|
46
|
+
return cls(policy=policy, source=path)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def find_policy_file(cls, start: Optional[Path] = None) -> Optional[Path]:
|
|
50
|
+
start_path = (start or Path.cwd()).expanduser().resolve()
|
|
51
|
+
if start_path.is_file():
|
|
52
|
+
start_path = start_path.parent
|
|
53
|
+
|
|
54
|
+
current = start_path
|
|
55
|
+
while True:
|
|
56
|
+
for name in DEFAULT_PROJECT_POLICY_FILES:
|
|
57
|
+
candidate = current / name
|
|
58
|
+
if candidate.exists() and candidate.is_file():
|
|
59
|
+
return candidate
|
|
60
|
+
|
|
61
|
+
if current.parent == current:
|
|
62
|
+
break
|
|
63
|
+
current = current.parent
|
|
64
|
+
|
|
65
|
+
if DEFAULT_GLOBAL_POLICY_FILE.exists() and DEFAULT_GLOBAL_POLICY_FILE.is_file():
|
|
66
|
+
return DEFAULT_GLOBAL_POLICY_FILE
|
|
67
|
+
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def load_effective(cls, start: Optional[Path] = None) -> Optional["PolicyEngine"]:
|
|
72
|
+
policy_path = cls.find_policy_file(start=start)
|
|
73
|
+
if not policy_path:
|
|
74
|
+
return None
|
|
75
|
+
return cls.load(policy_path)
|
|
76
|
+
|
|
77
|
+
def assert_url_allowed(self, url: str) -> None:
|
|
78
|
+
network = self.policy.policies.network
|
|
79
|
+
if network is None:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
hostname = extract_hostname(url)
|
|
83
|
+
if not hostname:
|
|
84
|
+
raise PolicyViolationError(f"URL has no hostname: {url}")
|
|
85
|
+
|
|
86
|
+
if not is_host_allowed(hostname, network.allowlist, network.blocklist):
|
|
87
|
+
raise PolicyViolationError(f"Network access denied by policy: {hostname}")
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _operation_matches(operation: str, pattern: str) -> bool:
|
|
91
|
+
operation = (operation or "").strip().lower()
|
|
92
|
+
pattern = (pattern or "").strip().lower()
|
|
93
|
+
if not operation or not pattern:
|
|
94
|
+
return False
|
|
95
|
+
return fnmatch.fnmatch(operation, pattern)
|
|
96
|
+
|
|
97
|
+
def requires_approval(self, operation: str) -> bool:
|
|
98
|
+
ops = self.policy.policies.operations
|
|
99
|
+
if ops is None:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if any(self._operation_matches(operation, p) for p in ops.auto_approve or []):
|
|
103
|
+
return False
|
|
104
|
+
return any(
|
|
105
|
+
self._operation_matches(operation, p) for p in ops.require_approval or []
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def assert_operation_approved(self, operation: str, approved: bool) -> None:
|
|
109
|
+
if self.requires_approval(operation) and not approved:
|
|
110
|
+
raise PolicyViolationError(
|
|
111
|
+
f"Operation requires approval by policy: {operation} (pass --approve)"
|
|
112
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkPolicy(BaseModel):
|
|
9
|
+
allowlist: List[str] = Field(default_factory=list)
|
|
10
|
+
blocklist: List[str] = Field(default_factory=list)
|
|
11
|
+
|
|
12
|
+
@field_validator("allowlist", "blocklist")
|
|
13
|
+
@classmethod
|
|
14
|
+
def _validate_patterns(cls, v: List[str]) -> List[str]:
|
|
15
|
+
if v is None:
|
|
16
|
+
return []
|
|
17
|
+
if not isinstance(v, list):
|
|
18
|
+
raise TypeError("must be a list")
|
|
19
|
+
for item in v:
|
|
20
|
+
if not isinstance(item, str) or not item.strip():
|
|
21
|
+
raise ValueError("patterns must be non-empty strings")
|
|
22
|
+
return v
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OperationsPolicy(BaseModel):
|
|
26
|
+
require_approval: List[str] = Field(default_factory=list)
|
|
27
|
+
auto_approve: List[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@field_validator("require_approval", "auto_approve")
|
|
30
|
+
@classmethod
|
|
31
|
+
def _validate_ops(cls, v: List[str]) -> List[str]:
|
|
32
|
+
if v is None:
|
|
33
|
+
return []
|
|
34
|
+
if not isinstance(v, list):
|
|
35
|
+
raise TypeError("must be a list")
|
|
36
|
+
for item in v:
|
|
37
|
+
if not isinstance(item, str) or not item.strip():
|
|
38
|
+
raise ValueError("operation names must be non-empty strings")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResourcesPolicy(BaseModel):
|
|
43
|
+
max_vms_per_user: Optional[int] = None
|
|
44
|
+
max_disk_gb: Optional[int] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PolicySet(BaseModel):
|
|
48
|
+
network: Optional[NetworkPolicy] = None
|
|
49
|
+
operations: Optional[OperationsPolicy] = None
|
|
50
|
+
resources: Optional[ResourcesPolicy] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PolicyFile(BaseModel):
|
|
54
|
+
version: str
|
|
55
|
+
policies: PolicySet
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from typing import List
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_hostname(url: str) -> str:
|
|
9
|
+
parsed = urlparse(url)
|
|
10
|
+
return (parsed.hostname or "").strip().lower()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def host_matches(hostname: str, pattern: str) -> bool:
|
|
14
|
+
hostname = (hostname or "").strip().lower()
|
|
15
|
+
pattern = (pattern or "").strip().lower()
|
|
16
|
+
if not hostname or not pattern:
|
|
17
|
+
return False
|
|
18
|
+
return fnmatch.fnmatch(hostname, pattern)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_host_allowed(hostname: str, allowlist: List[str], blocklist: List[str]) -> bool:
|
|
22
|
+
if any(host_matches(hostname, p) for p in blocklist or []):
|
|
23
|
+
return False
|
|
24
|
+
if allowlist:
|
|
25
|
+
return any(host_matches(hostname, p) for p in allowlist)
|
|
26
|
+
return True
|