@mriekki/lean 0.1.0 → 0.2.0
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.
- package/README.md +71 -67
- package/assets/components.json +33 -33
- package/assets/core/distribution.json +52 -51
- package/assets/deployment/distribution.json +34 -34
- package/assets/generated/ASSEMBLY.json +38 -33
- package/assets/generated/core/core/skills/core-sync/SKILL.md +78 -120
- package/assets/generated/core/core/skills/deploy/SKILL.md +74 -91
- package/assets/generated/core/core/skills/lean-update/SKILL.md +189 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +91 -76
- package/assets/generated/deployment/distribution/lean-poc-deploy.py +8 -8
- package/assets/generated/deployment/src/lean_poc_deployment/__init__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/__main__.py +3 -3
- package/assets/generated/deployment/src/lean_poc_deployment/app_cli.py +140 -140
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime.py +455 -455
- package/assets/generated/deployment/src/lean_poc_deployment/app_runtime_remote.py +415 -415
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform.py +208 -208
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_assets.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/box_platform_remote.py +271 -271
- package/assets/generated/deployment/src/lean_poc_deployment/cli.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/doctor.py +121 -121
- package/assets/generated/deployment/src/lean_poc_deployment/entrypoint.py +25 -25
- package/assets/generated/deployment/src/lean_poc_deployment/errors.py +5 -5
- package/assets/generated/deployment/src/lean_poc_deployment/keys.py +171 -171
- package/assets/generated/deployment/src/lean_poc_deployment/profiles.py +204 -204
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry.py +367 -367
- package/assets/generated/deployment/src/lean_poc_deployment/route_registry_remote.py +443 -443
- package/assets/generated/deployment/src/lean_poc_deployment/security.py +290 -290
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter.py +334 -334
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_adapter_remote.py +339 -339
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_cli.py +85 -85
- package/assets/generated/deployment/src/lean_poc_deployment/sqlite_import_recovery.py +437 -437
- package/assets/generated/deployment/src/lean_poc_deployment/ssh.py +118 -118
- package/assets/generated/deployment/src/lean_poc_deployment/teardown.py +286 -286
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_cli.py +120 -120
- package/assets/generated/deployment/src/lean_poc_deployment/teardown_remote.py +100 -100
- package/assets/generated/deployment/src/lean_poc_deployment/trust.py +278 -278
- package/bin/lean.mjs +9 -9
- package/package.json +33 -33
- package/src/cli.mjs +131 -131
- package/src/engine.mjs +243 -243
- package/src/package-data.mjs +18 -18
|
@@ -1,290 +1,290 @@
|
|
|
1
|
-
"""Filesystem integrity checks for SSH identity and host-trust material."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import base64
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import shutil
|
|
9
|
-
import stat
|
|
10
|
-
import subprocess
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
from .errors import DeploymentKitError
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
WINDOWS_SID_RE = __import__("re").compile(r"^S-1-(?:[0-9]+-){1,15}[0-9]+$")
|
|
18
|
-
HOST_KEY_TYPES = {"ssh-ed25519", "ecdsa-sha2-nistp256", "ssh-rsa"}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _valid_windows_sid(value: Any) -> bool:
|
|
22
|
-
if not isinstance(value, str) or WINDOWS_SID_RE.fullmatch(value) is None:
|
|
23
|
-
return False
|
|
24
|
-
components = value.split("-")
|
|
25
|
-
subauthorities = components[3:]
|
|
26
|
-
if not 1 <= len(subauthorities) <= 15:
|
|
27
|
-
return False
|
|
28
|
-
|
|
29
|
-
def within_bound(component: str, maximum: int) -> bool:
|
|
30
|
-
if component != "0" and component.startswith("0"):
|
|
31
|
-
return False
|
|
32
|
-
limit = str(maximum)
|
|
33
|
-
return len(component) < len(limit) or (len(component) == len(limit) and component <= limit)
|
|
34
|
-
|
|
35
|
-
return within_bound(components[2], 0xFFFFFFFFFFFF) and all(
|
|
36
|
-
within_bound(component, 0xFFFFFFFF) for component in subauthorities
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def validate_windows_acl_report(report: Any) -> None:
|
|
41
|
-
try:
|
|
42
|
-
if not isinstance(report, dict) or set(report) != {"currentUserSid", "entries"}:
|
|
43
|
-
raise ValueError("unexpected ACL report shape")
|
|
44
|
-
current_user = report["currentUserSid"]
|
|
45
|
-
entries = report["entries"]
|
|
46
|
-
if not _valid_windows_sid(current_user):
|
|
47
|
-
raise ValueError("invalid current-user SID")
|
|
48
|
-
if not isinstance(entries, list) or not entries:
|
|
49
|
-
raise ValueError("missing ACL entries")
|
|
50
|
-
|
|
51
|
-
trusted_sids = {current_user, "S-1-5-18", "S-1-5-32-544"}
|
|
52
|
-
write_rights = 2 | 4 | 16 | 64 | 256 | 65536 | 262144 | 524288
|
|
53
|
-
known_rights = (
|
|
54
|
-
1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 |
|
|
55
|
-
65536 | 131072 | 262144 | 524288 | 1048576
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
for entry in entries:
|
|
59
|
-
if (
|
|
60
|
-
not isinstance(entry, dict)
|
|
61
|
-
or set(entry) != {"path", "ownerSid", "reparsePoint", "rules"}
|
|
62
|
-
or not isinstance(entry["path"], str)
|
|
63
|
-
or not entry["path"]
|
|
64
|
-
or not _valid_windows_sid(entry["ownerSid"])
|
|
65
|
-
or type(entry["reparsePoint"]) is not bool
|
|
66
|
-
or not isinstance(entry["rules"], list)
|
|
67
|
-
):
|
|
68
|
-
raise ValueError("malformed ACL entry")
|
|
69
|
-
if entry["reparsePoint"] or entry["ownerSid"] not in trusted_sids:
|
|
70
|
-
raise DeploymentKitError(
|
|
71
|
-
f"Windows SSH material has an untrusted owner or reparse point: {entry['path']}"
|
|
72
|
-
)
|
|
73
|
-
for rule in entry["rules"]:
|
|
74
|
-
if (
|
|
75
|
-
not isinstance(rule, dict)
|
|
76
|
-
or set(rule) != {"sid", "rights", "type"}
|
|
77
|
-
or not _valid_windows_sid(rule["sid"])
|
|
78
|
-
or rule["type"] not in {"Allow", "Deny"}
|
|
79
|
-
or type(rule["rights"]) is not int
|
|
80
|
-
or rule["rights"] < 0
|
|
81
|
-
or rule["rights"] & ~known_rights
|
|
82
|
-
):
|
|
83
|
-
raise ValueError("malformed ACL rule")
|
|
84
|
-
if (
|
|
85
|
-
rule["type"] == "Allow"
|
|
86
|
-
and rule["sid"] not in trusted_sids
|
|
87
|
-
and rule["rights"] & write_rights
|
|
88
|
-
):
|
|
89
|
-
raise DeploymentKitError(
|
|
90
|
-
f"Windows SSH material grants write access to an untrusted principal: {entry['path']}"
|
|
91
|
-
)
|
|
92
|
-
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
93
|
-
raise DeploymentKitError(
|
|
94
|
-
"Windows SSH-material ACL report was malformed; verification is blocked."
|
|
95
|
-
) from error
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _ssh_directory_for(path: Path) -> Path:
|
|
99
|
-
candidates = (path, *path.parents)
|
|
100
|
-
ssh_directory = next((candidate for candidate in candidates if candidate.name.lower() == ".ssh"), None)
|
|
101
|
-
if ssh_directory is None:
|
|
102
|
-
raise DeploymentKitError("SSH material is not inside the user's .ssh directory.")
|
|
103
|
-
return ssh_directory
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _check_windows_acl(path: Path) -> None:
|
|
107
|
-
powershell = shutil.which("pwsh") or shutil.which("powershell")
|
|
108
|
-
if powershell is None:
|
|
109
|
-
raise DeploymentKitError(
|
|
110
|
-
"Cannot verify Windows SSH-material ACLs because PowerShell is unavailable."
|
|
111
|
-
)
|
|
112
|
-
ssh_directory = _ssh_directory_for(path)
|
|
113
|
-
|
|
114
|
-
checked_paths = [path]
|
|
115
|
-
directory = path.parent
|
|
116
|
-
while True:
|
|
117
|
-
checked_paths.append(directory)
|
|
118
|
-
if directory == ssh_directory.parent:
|
|
119
|
-
break
|
|
120
|
-
if directory != ssh_directory and ssh_directory not in directory.parents:
|
|
121
|
-
raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
|
|
122
|
-
directory = directory.parent
|
|
123
|
-
|
|
124
|
-
serialized_paths = json.dumps([str(candidate) for candidate in dict.fromkeys(checked_paths)])
|
|
125
|
-
serialized_paths = serialized_paths.replace("'", "''")
|
|
126
|
-
script = f"""
|
|
127
|
-
$ErrorActionPreference = 'Stop'
|
|
128
|
-
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
129
|
-
$paths = @(ConvertFrom-Json -InputObject '{serialized_paths}')
|
|
130
|
-
$userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
131
|
-
$entries = foreach ($candidate in $paths) {{
|
|
132
|
-
$item = Get-Item -LiteralPath $candidate -Force
|
|
133
|
-
$acl = Get-Acl -LiteralPath $candidate
|
|
134
|
-
$rules = foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {{
|
|
135
|
-
[ordered]@{{
|
|
136
|
-
sid = $rule.IdentityReference.Value
|
|
137
|
-
rights = [int]$rule.FileSystemRights
|
|
138
|
-
type = $rule.AccessControlType.ToString()
|
|
139
|
-
}}
|
|
140
|
-
}}
|
|
141
|
-
[ordered]@{{
|
|
142
|
-
path = $candidate
|
|
143
|
-
ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
144
|
-
reparsePoint = (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
|
145
|
-
rules = @($rules)
|
|
146
|
-
}}
|
|
147
|
-
}}
|
|
148
|
-
[ordered]@{{ currentUserSid = $userSid; entries = @($entries) }} | ConvertTo-Json -Depth 6 -Compress
|
|
149
|
-
"""
|
|
150
|
-
result = subprocess.run(
|
|
151
|
-
[powershell, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
152
|
-
check=False,
|
|
153
|
-
capture_output=True,
|
|
154
|
-
text=True,
|
|
155
|
-
encoding="utf-8",
|
|
156
|
-
errors="replace",
|
|
157
|
-
)
|
|
158
|
-
if result.returncode != 0:
|
|
159
|
-
raise DeploymentKitError(
|
|
160
|
-
"Cannot verify Windows SSH-material ACLs; access verification is blocked."
|
|
161
|
-
)
|
|
162
|
-
try:
|
|
163
|
-
validate_windows_acl_report(json.loads(result.stdout))
|
|
164
|
-
except (TypeError, ValueError, json.JSONDecodeError) as error:
|
|
165
|
-
raise DeploymentKitError(
|
|
166
|
-
"Windows SSH-material ACL report was malformed; verification is blocked."
|
|
167
|
-
) from error
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _posix_directory_chain(path: Path) -> list[Path]:
|
|
171
|
-
ssh_directory = _ssh_directory_for(path)
|
|
172
|
-
directories: list[Path] = []
|
|
173
|
-
current = path.parent
|
|
174
|
-
while True:
|
|
175
|
-
directories.append(current)
|
|
176
|
-
if current == ssh_directory:
|
|
177
|
-
return directories
|
|
178
|
-
if current == current.parent:
|
|
179
|
-
raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
|
|
180
|
-
current = current.parent
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _check_posix_directories(path: Path) -> None:
|
|
184
|
-
for directory in _posix_directory_chain(path):
|
|
185
|
-
if directory.is_symlink() or not directory.is_dir():
|
|
186
|
-
raise DeploymentKitError(f"SSH directory must be a real directory: {directory}")
|
|
187
|
-
if directory.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
188
|
-
raise DeploymentKitError(f"SSH directory permissions are too broad: {directory}")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def prepare_ssh_directory(path: Path) -> None:
|
|
192
|
-
"""Create one SSH-material directory when absent, then verify its safety before secrets are written."""
|
|
193
|
-
_ssh_directory_for(path)
|
|
194
|
-
if path.exists():
|
|
195
|
-
if path.is_symlink() or not path.is_dir():
|
|
196
|
-
raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
|
|
197
|
-
else:
|
|
198
|
-
path.mkdir(parents=True, mode=0o700)
|
|
199
|
-
if os.name != "nt":
|
|
200
|
-
path.chmod(0o700)
|
|
201
|
-
_check_posix_directories(path / "placeholder")
|
|
202
|
-
else:
|
|
203
|
-
_check_windows_acl(path)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def check_identity_security(identity: Path) -> None:
|
|
207
|
-
if not identity.is_file():
|
|
208
|
-
raise DeploymentKitError(f"Required SSH identity is missing: {identity}")
|
|
209
|
-
if identity.is_symlink():
|
|
210
|
-
raise DeploymentKitError(f"SSH material must not use symbolic links: {identity}")
|
|
211
|
-
if os.name == "nt":
|
|
212
|
-
_check_windows_acl(identity)
|
|
213
|
-
else:
|
|
214
|
-
_check_posix_directories(identity)
|
|
215
|
-
if identity.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
216
|
-
raise DeploymentKitError(f"Private key permissions are too broad: {identity}")
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def check_known_hosts_security(known_hosts: Path) -> None:
|
|
220
|
-
if not known_hosts.is_file():
|
|
221
|
-
raise DeploymentKitError(f"Required SSH trust file is missing: {known_hosts}")
|
|
222
|
-
if known_hosts.is_symlink():
|
|
223
|
-
raise DeploymentKitError(f"SSH material must not use symbolic links: {known_hosts}")
|
|
224
|
-
if os.name == "nt":
|
|
225
|
-
_check_windows_acl(known_hosts)
|
|
226
|
-
else:
|
|
227
|
-
_check_posix_directories(known_hosts)
|
|
228
|
-
if known_hosts.stat().st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
229
|
-
raise DeploymentKitError(f"Target trust file is writable by another principal: {known_hosts}")
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def check_ssh_material_security(identity: Path, known_hosts: Path) -> None:
|
|
233
|
-
check_identity_security(identity)
|
|
234
|
-
check_known_hosts_security(known_hosts)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def known_hosts_token(profile: dict[str, Any]) -> str:
|
|
238
|
-
alias = profile["sshAlias"]
|
|
239
|
-
port = profile["port"]
|
|
240
|
-
return alias if port == 22 else f"[{alias}]:{port}"
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def validate_target_trust_file(
|
|
244
|
-
profile: dict[str, Any],
|
|
245
|
-
*,
|
|
246
|
-
allow_missing_or_empty: bool = False,
|
|
247
|
-
) -> list[tuple[str, str]]:
|
|
248
|
-
"""Validate that a target trust file contains only one exact stable-alias host key."""
|
|
249
|
-
path = Path(profile["knownHostsFile"])
|
|
250
|
-
if not path.exists():
|
|
251
|
-
if allow_missing_or_empty:
|
|
252
|
-
return []
|
|
253
|
-
raise DeploymentKitError(f"Target-specific trust file is missing: {path}")
|
|
254
|
-
if path.is_symlink() or not path.is_file():
|
|
255
|
-
raise DeploymentKitError(f"Target-specific trust file is not a real file: {path}")
|
|
256
|
-
try:
|
|
257
|
-
raw_lines = path.read_text(encoding="utf-8").splitlines()
|
|
258
|
-
except (OSError, UnicodeError) as error:
|
|
259
|
-
raise DeploymentKitError(f"Cannot read target-specific trust file {path}: {error}") from error
|
|
260
|
-
|
|
261
|
-
lines = [line.strip() for line in raw_lines if line.strip()]
|
|
262
|
-
if not lines:
|
|
263
|
-
if allow_missing_or_empty:
|
|
264
|
-
return []
|
|
265
|
-
raise DeploymentKitError(f"Target-specific trust file is empty: {path}")
|
|
266
|
-
if len(lines) != 1:
|
|
267
|
-
raise DeploymentKitError(
|
|
268
|
-
"Target-specific trust file must contain exactly one literal stable-alias host key."
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
fields = lines[0].split()
|
|
272
|
-
if len(fields) != 3:
|
|
273
|
-
raise DeploymentKitError("Target-specific trust entry must contain exactly host, key type, and key data.")
|
|
274
|
-
host_token, key_type, key_data = fields
|
|
275
|
-
expected = known_hosts_token(profile)
|
|
276
|
-
if host_token != expected:
|
|
277
|
-
raise DeploymentKitError(
|
|
278
|
-
f"Target-specific trust entry must use only the stable alias token '{expected}'."
|
|
279
|
-
)
|
|
280
|
-
if any(marker in host_token for marker in ("*", "?", ",", "|")) or host_token.startswith("@"):
|
|
281
|
-
raise DeploymentKitError("Wildcard, hashed, marked, or multi-host trust entries are not allowed.")
|
|
282
|
-
if key_type not in HOST_KEY_TYPES:
|
|
283
|
-
raise DeploymentKitError(f"Unsupported target host-key type in trust file: {key_type}")
|
|
284
|
-
try:
|
|
285
|
-
decoded = base64.b64decode(key_data.encode("ascii"), validate=True)
|
|
286
|
-
except (ValueError, UnicodeError) as error:
|
|
287
|
-
raise DeploymentKitError("Target-specific trust entry contains invalid key data.") from error
|
|
288
|
-
if not decoded:
|
|
289
|
-
raise DeploymentKitError("Target-specific trust entry contains empty key data.")
|
|
290
|
-
return [(key_type, key_data)]
|
|
1
|
+
"""Filesystem integrity checks for SSH identity and host-trust material."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import DeploymentKitError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
WINDOWS_SID_RE = __import__("re").compile(r"^S-1-(?:[0-9]+-){1,15}[0-9]+$")
|
|
18
|
+
HOST_KEY_TYPES = {"ssh-ed25519", "ecdsa-sha2-nistp256", "ssh-rsa"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _valid_windows_sid(value: Any) -> bool:
|
|
22
|
+
if not isinstance(value, str) or WINDOWS_SID_RE.fullmatch(value) is None:
|
|
23
|
+
return False
|
|
24
|
+
components = value.split("-")
|
|
25
|
+
subauthorities = components[3:]
|
|
26
|
+
if not 1 <= len(subauthorities) <= 15:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
def within_bound(component: str, maximum: int) -> bool:
|
|
30
|
+
if component != "0" and component.startswith("0"):
|
|
31
|
+
return False
|
|
32
|
+
limit = str(maximum)
|
|
33
|
+
return len(component) < len(limit) or (len(component) == len(limit) and component <= limit)
|
|
34
|
+
|
|
35
|
+
return within_bound(components[2], 0xFFFFFFFFFFFF) and all(
|
|
36
|
+
within_bound(component, 0xFFFFFFFF) for component in subauthorities
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_windows_acl_report(report: Any) -> None:
|
|
41
|
+
try:
|
|
42
|
+
if not isinstance(report, dict) or set(report) != {"currentUserSid", "entries"}:
|
|
43
|
+
raise ValueError("unexpected ACL report shape")
|
|
44
|
+
current_user = report["currentUserSid"]
|
|
45
|
+
entries = report["entries"]
|
|
46
|
+
if not _valid_windows_sid(current_user):
|
|
47
|
+
raise ValueError("invalid current-user SID")
|
|
48
|
+
if not isinstance(entries, list) or not entries:
|
|
49
|
+
raise ValueError("missing ACL entries")
|
|
50
|
+
|
|
51
|
+
trusted_sids = {current_user, "S-1-5-18", "S-1-5-32-544"}
|
|
52
|
+
write_rights = 2 | 4 | 16 | 64 | 256 | 65536 | 262144 | 524288
|
|
53
|
+
known_rights = (
|
|
54
|
+
1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 |
|
|
55
|
+
65536 | 131072 | 262144 | 524288 | 1048576
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for entry in entries:
|
|
59
|
+
if (
|
|
60
|
+
not isinstance(entry, dict)
|
|
61
|
+
or set(entry) != {"path", "ownerSid", "reparsePoint", "rules"}
|
|
62
|
+
or not isinstance(entry["path"], str)
|
|
63
|
+
or not entry["path"]
|
|
64
|
+
or not _valid_windows_sid(entry["ownerSid"])
|
|
65
|
+
or type(entry["reparsePoint"]) is not bool
|
|
66
|
+
or not isinstance(entry["rules"], list)
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("malformed ACL entry")
|
|
69
|
+
if entry["reparsePoint"] or entry["ownerSid"] not in trusted_sids:
|
|
70
|
+
raise DeploymentKitError(
|
|
71
|
+
f"Windows SSH material has an untrusted owner or reparse point: {entry['path']}"
|
|
72
|
+
)
|
|
73
|
+
for rule in entry["rules"]:
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(rule, dict)
|
|
76
|
+
or set(rule) != {"sid", "rights", "type"}
|
|
77
|
+
or not _valid_windows_sid(rule["sid"])
|
|
78
|
+
or rule["type"] not in {"Allow", "Deny"}
|
|
79
|
+
or type(rule["rights"]) is not int
|
|
80
|
+
or rule["rights"] < 0
|
|
81
|
+
or rule["rights"] & ~known_rights
|
|
82
|
+
):
|
|
83
|
+
raise ValueError("malformed ACL rule")
|
|
84
|
+
if (
|
|
85
|
+
rule["type"] == "Allow"
|
|
86
|
+
and rule["sid"] not in trusted_sids
|
|
87
|
+
and rule["rights"] & write_rights
|
|
88
|
+
):
|
|
89
|
+
raise DeploymentKitError(
|
|
90
|
+
f"Windows SSH material grants write access to an untrusted principal: {entry['path']}"
|
|
91
|
+
)
|
|
92
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
93
|
+
raise DeploymentKitError(
|
|
94
|
+
"Windows SSH-material ACL report was malformed; verification is blocked."
|
|
95
|
+
) from error
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ssh_directory_for(path: Path) -> Path:
|
|
99
|
+
candidates = (path, *path.parents)
|
|
100
|
+
ssh_directory = next((candidate for candidate in candidates if candidate.name.lower() == ".ssh"), None)
|
|
101
|
+
if ssh_directory is None:
|
|
102
|
+
raise DeploymentKitError("SSH material is not inside the user's .ssh directory.")
|
|
103
|
+
return ssh_directory
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_windows_acl(path: Path) -> None:
|
|
107
|
+
powershell = shutil.which("pwsh") or shutil.which("powershell")
|
|
108
|
+
if powershell is None:
|
|
109
|
+
raise DeploymentKitError(
|
|
110
|
+
"Cannot verify Windows SSH-material ACLs because PowerShell is unavailable."
|
|
111
|
+
)
|
|
112
|
+
ssh_directory = _ssh_directory_for(path)
|
|
113
|
+
|
|
114
|
+
checked_paths = [path]
|
|
115
|
+
directory = path.parent
|
|
116
|
+
while True:
|
|
117
|
+
checked_paths.append(directory)
|
|
118
|
+
if directory == ssh_directory.parent:
|
|
119
|
+
break
|
|
120
|
+
if directory != ssh_directory and ssh_directory not in directory.parents:
|
|
121
|
+
raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
|
|
122
|
+
directory = directory.parent
|
|
123
|
+
|
|
124
|
+
serialized_paths = json.dumps([str(candidate) for candidate in dict.fromkeys(checked_paths)])
|
|
125
|
+
serialized_paths = serialized_paths.replace("'", "''")
|
|
126
|
+
script = f"""
|
|
127
|
+
$ErrorActionPreference = 'Stop'
|
|
128
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
129
|
+
$paths = @(ConvertFrom-Json -InputObject '{serialized_paths}')
|
|
130
|
+
$userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
131
|
+
$entries = foreach ($candidate in $paths) {{
|
|
132
|
+
$item = Get-Item -LiteralPath $candidate -Force
|
|
133
|
+
$acl = Get-Acl -LiteralPath $candidate
|
|
134
|
+
$rules = foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {{
|
|
135
|
+
[ordered]@{{
|
|
136
|
+
sid = $rule.IdentityReference.Value
|
|
137
|
+
rights = [int]$rule.FileSystemRights
|
|
138
|
+
type = $rule.AccessControlType.ToString()
|
|
139
|
+
}}
|
|
140
|
+
}}
|
|
141
|
+
[ordered]@{{
|
|
142
|
+
path = $candidate
|
|
143
|
+
ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
144
|
+
reparsePoint = (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
|
145
|
+
rules = @($rules)
|
|
146
|
+
}}
|
|
147
|
+
}}
|
|
148
|
+
[ordered]@{{ currentUserSid = $userSid; entries = @($entries) }} | ConvertTo-Json -Depth 6 -Compress
|
|
149
|
+
"""
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
[powershell, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
152
|
+
check=False,
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
errors="replace",
|
|
157
|
+
)
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
raise DeploymentKitError(
|
|
160
|
+
"Cannot verify Windows SSH-material ACLs; access verification is blocked."
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
validate_windows_acl_report(json.loads(result.stdout))
|
|
164
|
+
except (TypeError, ValueError, json.JSONDecodeError) as error:
|
|
165
|
+
raise DeploymentKitError(
|
|
166
|
+
"Windows SSH-material ACL report was malformed; verification is blocked."
|
|
167
|
+
) from error
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _posix_directory_chain(path: Path) -> list[Path]:
|
|
171
|
+
ssh_directory = _ssh_directory_for(path)
|
|
172
|
+
directories: list[Path] = []
|
|
173
|
+
current = path.parent
|
|
174
|
+
while True:
|
|
175
|
+
directories.append(current)
|
|
176
|
+
if current == ssh_directory:
|
|
177
|
+
return directories
|
|
178
|
+
if current == current.parent:
|
|
179
|
+
raise DeploymentKitError("SSH-material path escaped the user's .ssh directory.")
|
|
180
|
+
current = current.parent
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_posix_directories(path: Path) -> None:
|
|
184
|
+
for directory in _posix_directory_chain(path):
|
|
185
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
186
|
+
raise DeploymentKitError(f"SSH directory must be a real directory: {directory}")
|
|
187
|
+
if directory.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
188
|
+
raise DeploymentKitError(f"SSH directory permissions are too broad: {directory}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def prepare_ssh_directory(path: Path) -> None:
|
|
192
|
+
"""Create one SSH-material directory when absent, then verify its safety before secrets are written."""
|
|
193
|
+
_ssh_directory_for(path)
|
|
194
|
+
if path.exists():
|
|
195
|
+
if path.is_symlink() or not path.is_dir():
|
|
196
|
+
raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
|
|
197
|
+
else:
|
|
198
|
+
path.mkdir(parents=True, mode=0o700)
|
|
199
|
+
if os.name != "nt":
|
|
200
|
+
path.chmod(0o700)
|
|
201
|
+
_check_posix_directories(path / "placeholder")
|
|
202
|
+
else:
|
|
203
|
+
_check_windows_acl(path)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def check_identity_security(identity: Path) -> None:
|
|
207
|
+
if not identity.is_file():
|
|
208
|
+
raise DeploymentKitError(f"Required SSH identity is missing: {identity}")
|
|
209
|
+
if identity.is_symlink():
|
|
210
|
+
raise DeploymentKitError(f"SSH material must not use symbolic links: {identity}")
|
|
211
|
+
if os.name == "nt":
|
|
212
|
+
_check_windows_acl(identity)
|
|
213
|
+
else:
|
|
214
|
+
_check_posix_directories(identity)
|
|
215
|
+
if identity.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
216
|
+
raise DeploymentKitError(f"Private key permissions are too broad: {identity}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_known_hosts_security(known_hosts: Path) -> None:
|
|
220
|
+
if not known_hosts.is_file():
|
|
221
|
+
raise DeploymentKitError(f"Required SSH trust file is missing: {known_hosts}")
|
|
222
|
+
if known_hosts.is_symlink():
|
|
223
|
+
raise DeploymentKitError(f"SSH material must not use symbolic links: {known_hosts}")
|
|
224
|
+
if os.name == "nt":
|
|
225
|
+
_check_windows_acl(known_hosts)
|
|
226
|
+
else:
|
|
227
|
+
_check_posix_directories(known_hosts)
|
|
228
|
+
if known_hosts.stat().st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
229
|
+
raise DeploymentKitError(f"Target trust file is writable by another principal: {known_hosts}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_ssh_material_security(identity: Path, known_hosts: Path) -> None:
|
|
233
|
+
check_identity_security(identity)
|
|
234
|
+
check_known_hosts_security(known_hosts)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def known_hosts_token(profile: dict[str, Any]) -> str:
|
|
238
|
+
alias = profile["sshAlias"]
|
|
239
|
+
port = profile["port"]
|
|
240
|
+
return alias if port == 22 else f"[{alias}]:{port}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def validate_target_trust_file(
|
|
244
|
+
profile: dict[str, Any],
|
|
245
|
+
*,
|
|
246
|
+
allow_missing_or_empty: bool = False,
|
|
247
|
+
) -> list[tuple[str, str]]:
|
|
248
|
+
"""Validate that a target trust file contains only one exact stable-alias host key."""
|
|
249
|
+
path = Path(profile["knownHostsFile"])
|
|
250
|
+
if not path.exists():
|
|
251
|
+
if allow_missing_or_empty:
|
|
252
|
+
return []
|
|
253
|
+
raise DeploymentKitError(f"Target-specific trust file is missing: {path}")
|
|
254
|
+
if path.is_symlink() or not path.is_file():
|
|
255
|
+
raise DeploymentKitError(f"Target-specific trust file is not a real file: {path}")
|
|
256
|
+
try:
|
|
257
|
+
raw_lines = path.read_text(encoding="utf-8").splitlines()
|
|
258
|
+
except (OSError, UnicodeError) as error:
|
|
259
|
+
raise DeploymentKitError(f"Cannot read target-specific trust file {path}: {error}") from error
|
|
260
|
+
|
|
261
|
+
lines = [line.strip() for line in raw_lines if line.strip()]
|
|
262
|
+
if not lines:
|
|
263
|
+
if allow_missing_or_empty:
|
|
264
|
+
return []
|
|
265
|
+
raise DeploymentKitError(f"Target-specific trust file is empty: {path}")
|
|
266
|
+
if len(lines) != 1:
|
|
267
|
+
raise DeploymentKitError(
|
|
268
|
+
"Target-specific trust file must contain exactly one literal stable-alias host key."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
fields = lines[0].split()
|
|
272
|
+
if len(fields) != 3:
|
|
273
|
+
raise DeploymentKitError("Target-specific trust entry must contain exactly host, key type, and key data.")
|
|
274
|
+
host_token, key_type, key_data = fields
|
|
275
|
+
expected = known_hosts_token(profile)
|
|
276
|
+
if host_token != expected:
|
|
277
|
+
raise DeploymentKitError(
|
|
278
|
+
f"Target-specific trust entry must use only the stable alias token '{expected}'."
|
|
279
|
+
)
|
|
280
|
+
if any(marker in host_token for marker in ("*", "?", ",", "|")) or host_token.startswith("@"):
|
|
281
|
+
raise DeploymentKitError("Wildcard, hashed, marked, or multi-host trust entries are not allowed.")
|
|
282
|
+
if key_type not in HOST_KEY_TYPES:
|
|
283
|
+
raise DeploymentKitError(f"Unsupported target host-key type in trust file: {key_type}")
|
|
284
|
+
try:
|
|
285
|
+
decoded = base64.b64decode(key_data.encode("ascii"), validate=True)
|
|
286
|
+
except (ValueError, UnicodeError) as error:
|
|
287
|
+
raise DeploymentKitError("Target-specific trust entry contains invalid key data.") from error
|
|
288
|
+
if not decoded:
|
|
289
|
+
raise DeploymentKitError("Target-specific trust entry contains empty key data.")
|
|
290
|
+
return [(key_type, key_data)]
|