@mriekki/project-forge 4.1.0 → 4.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/assets/components.json +3 -3
- package/assets/deployment/distribution.json +1 -0
- package/assets/generated/ASSEMBLY.json +16 -11
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +3 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +61 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +137 -18
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +26 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +7 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +106 -24
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +22 -1
- package/assets/generated/deployment/src/project_forge_deployment/proof_receipt.py +183 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +277 -23
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Strict SSH verification
|
|
1
|
+
"""Strict SSH verification and bounded diagnostics for logical targets."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import socket
|
|
7
8
|
import subprocess
|
|
@@ -9,9 +10,14 @@ from pathlib import Path
|
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from .errors import DeploymentKitError
|
|
13
|
+
from .keys import key_fingerprint
|
|
12
14
|
from .security import check_ssh_material_security, validate_target_trust_file
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
_AUTH_METHODS_RE = re.compile(r"Authentications that can continue:\s*(?P<methods>[^\r\n]+)")
|
|
18
|
+
_PERMISSION_DENIED_RE = re.compile(r"Permission denied \((?P<methods>[^)]+)\)")
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def required_tool(name: str) -> str:
|
|
16
22
|
path = shutil.which(name)
|
|
17
23
|
if path is None:
|
|
@@ -40,7 +46,7 @@ def resolve_addresses(profile: dict[str, Any]) -> list[str]:
|
|
|
40
46
|
return addresses
|
|
41
47
|
|
|
42
48
|
|
|
43
|
-
def
|
|
49
|
+
def _strict_transport_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
44
50
|
return [
|
|
45
51
|
required_tool("ssh"),
|
|
46
52
|
"-F",
|
|
@@ -64,16 +70,6 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
64
70
|
"-o",
|
|
65
71
|
f"HostKeyAlias={profile['sshAlias']}",
|
|
66
72
|
"-o",
|
|
67
|
-
f"IdentityFile={profile['identityFile']}",
|
|
68
|
-
"-o",
|
|
69
|
-
"IdentitiesOnly=yes",
|
|
70
|
-
"-o",
|
|
71
|
-
"PasswordAuthentication=no",
|
|
72
|
-
"-o",
|
|
73
|
-
"KbdInteractiveAuthentication=no",
|
|
74
|
-
"-o",
|
|
75
|
-
"PreferredAuthentications=publickey",
|
|
76
|
-
"-o",
|
|
77
73
|
"ForwardAgent=no",
|
|
78
74
|
"-o",
|
|
79
75
|
"ForwardX11=no",
|
|
@@ -81,11 +77,277 @@ def ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") ->
|
|
|
81
77
|
"ClearAllForwardings=yes",
|
|
82
78
|
"-p",
|
|
83
79
|
str(profile["port"]),
|
|
84
|
-
f"{profile['user']}@{address}",
|
|
85
|
-
command,
|
|
86
80
|
]
|
|
87
81
|
|
|
88
82
|
|
|
83
|
+
def ssh_args(
|
|
84
|
+
profile: dict[str, Any],
|
|
85
|
+
address: str,
|
|
86
|
+
*,
|
|
87
|
+
command: str = "true",
|
|
88
|
+
verbose: bool = False,
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
args = _strict_transport_args(profile, address)
|
|
91
|
+
if verbose:
|
|
92
|
+
args.insert(1, "-vv")
|
|
93
|
+
args.extend(
|
|
94
|
+
[
|
|
95
|
+
"-o",
|
|
96
|
+
f"IdentityFile={profile['identityFile']}",
|
|
97
|
+
"-o",
|
|
98
|
+
"IdentitiesOnly=yes",
|
|
99
|
+
"-o",
|
|
100
|
+
"PasswordAuthentication=no",
|
|
101
|
+
"-o",
|
|
102
|
+
"KbdInteractiveAuthentication=no",
|
|
103
|
+
"-o",
|
|
104
|
+
"PreferredAuthentications=publickey",
|
|
105
|
+
f"{profile['user']}@{address}",
|
|
106
|
+
command,
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
return args
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def authentication_probe_args(profile: dict[str, Any], address: str) -> list[str]:
|
|
113
|
+
"""Build a read-only SSH handshake that discovers advertised auth methods.
|
|
114
|
+
|
|
115
|
+
Host identity is still verified through the configured target-specific trust file.
|
|
116
|
+
All actual client authentication mechanisms are disabled so the server's initial
|
|
117
|
+
authentication-method advertisement can be observed without attempting credentials.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
args = _strict_transport_args(profile, address)
|
|
121
|
+
args.insert(1, "-vv")
|
|
122
|
+
args.extend(
|
|
123
|
+
[
|
|
124
|
+
"-o",
|
|
125
|
+
"PubkeyAuthentication=no",
|
|
126
|
+
"-o",
|
|
127
|
+
"PasswordAuthentication=no",
|
|
128
|
+
"-o",
|
|
129
|
+
"KbdInteractiveAuthentication=no",
|
|
130
|
+
"-o",
|
|
131
|
+
"GSSAPIAuthentication=no",
|
|
132
|
+
"-o",
|
|
133
|
+
"HostbasedAuthentication=no",
|
|
134
|
+
f"{profile['user']}@{address}",
|
|
135
|
+
"true",
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
return args
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _completed(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
args,
|
|
144
|
+
check=False,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
errors="replace",
|
|
149
|
+
stdin=subprocess.DEVNULL,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _auth_methods(text: str) -> list[str]:
|
|
154
|
+
methods: list[str] = []
|
|
155
|
+
for match in _AUTH_METHODS_RE.finditer(text):
|
|
156
|
+
for method in match.group("methods").split(","):
|
|
157
|
+
value = method.strip()
|
|
158
|
+
if value and value not in methods:
|
|
159
|
+
methods.append(value)
|
|
160
|
+
if not methods:
|
|
161
|
+
denied = _PERMISSION_DENIED_RE.search(text)
|
|
162
|
+
if denied:
|
|
163
|
+
for method in denied.group("methods").split(","):
|
|
164
|
+
value = method.strip()
|
|
165
|
+
if value and value not in methods:
|
|
166
|
+
methods.append(value)
|
|
167
|
+
return methods
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def probe_authentication_methods(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
171
|
+
result = _completed(authentication_probe_args(profile, address))
|
|
172
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
173
|
+
methods = _auth_methods(text)
|
|
174
|
+
return {
|
|
175
|
+
"address": address,
|
|
176
|
+
"methods": methods,
|
|
177
|
+
"passwordSupported": "password" in methods or "keyboard-interactive" in methods,
|
|
178
|
+
"publicKeySupported": "publickey" in methods,
|
|
179
|
+
"observed": bool(methods),
|
|
180
|
+
"returnCode": result.returncode,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tcp_probe(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
with socket.create_connection((address, int(profile["port"])), timeout=8):
|
|
187
|
+
pass
|
|
188
|
+
return {"state": "reachable", "address": address, "port": int(profile["port"])}
|
|
189
|
+
except OSError as error:
|
|
190
|
+
return {
|
|
191
|
+
"state": "unreachable",
|
|
192
|
+
"address": address,
|
|
193
|
+
"port": int(profile["port"]),
|
|
194
|
+
"reason": str(error),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _classify_failure(text: str, *, auth: dict[str, Any], offered: bool, accepted: bool) -> str:
|
|
199
|
+
lower = text.lower()
|
|
200
|
+
if "remote host identification has changed" in lower or "host key verification failed" in lower:
|
|
201
|
+
return "host-identity-rejected"
|
|
202
|
+
if any(
|
|
203
|
+
marker in lower
|
|
204
|
+
for marker in (
|
|
205
|
+
"sign_and_send_pubkey: signing failed",
|
|
206
|
+
"agent refused operation",
|
|
207
|
+
"incorrect passphrase",
|
|
208
|
+
"load key",
|
|
209
|
+
)
|
|
210
|
+
):
|
|
211
|
+
return "local-signing-failed"
|
|
212
|
+
if "connection timed out" in lower or "connection refused" in lower or "no route to host" in lower:
|
|
213
|
+
return "transport-failed"
|
|
214
|
+
if "permission denied" in lower:
|
|
215
|
+
if accepted:
|
|
216
|
+
return "remote-account-policy-rejected"
|
|
217
|
+
if offered or auth.get("publicKeySupported"):
|
|
218
|
+
return "remote-authentication-rejected"
|
|
219
|
+
return "authentication-rejected"
|
|
220
|
+
return "ssh-verification-failed"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
"""Return safe structured SSH failure evidence without changing workstation or target state."""
|
|
225
|
+
|
|
226
|
+
identity = Path(profile["identityFile"])
|
|
227
|
+
known_hosts = Path(profile["knownHostsFile"])
|
|
228
|
+
if not identity.is_file():
|
|
229
|
+
return {
|
|
230
|
+
"targetName": profile["targetName"],
|
|
231
|
+
"state": "failed",
|
|
232
|
+
"failureCategory": "missing-identity",
|
|
233
|
+
"reason": f"Dedicated private key is missing: {identity}",
|
|
234
|
+
"mutated": False,
|
|
235
|
+
}
|
|
236
|
+
if not known_hosts.is_file() or known_hosts.stat().st_size == 0:
|
|
237
|
+
return {
|
|
238
|
+
"targetName": profile["targetName"],
|
|
239
|
+
"state": "failed",
|
|
240
|
+
"failureCategory": "missing-trust",
|
|
241
|
+
"reason": f"Target-specific trust file is missing or empty: {known_hosts}",
|
|
242
|
+
"mutated": False,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
check_ssh_material_security(identity, known_hosts)
|
|
247
|
+
validate_target_trust_file(profile)
|
|
248
|
+
except DeploymentKitError as error:
|
|
249
|
+
return {
|
|
250
|
+
"targetName": profile["targetName"],
|
|
251
|
+
"state": "failed",
|
|
252
|
+
"failureCategory": "local-security-or-trust-invalid",
|
|
253
|
+
"reason": str(error),
|
|
254
|
+
"mutated": False,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
addresses = resolve_addresses(profile)
|
|
259
|
+
except DeploymentKitError as error:
|
|
260
|
+
return {
|
|
261
|
+
"targetName": profile["targetName"],
|
|
262
|
+
"state": "failed",
|
|
263
|
+
"failureCategory": "name-resolution-failed",
|
|
264
|
+
"reason": str(error),
|
|
265
|
+
"mutated": False,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fingerprint = key_fingerprint(identity)
|
|
269
|
+
attempts: list[dict[str, Any]] = []
|
|
270
|
+
for address in addresses:
|
|
271
|
+
tcp = _tcp_probe(profile, address)
|
|
272
|
+
if tcp["state"] != "reachable":
|
|
273
|
+
attempts.append({"address": address, "tcp": tcp, "state": "transport-failed"})
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
auth = probe_authentication_methods(profile, address)
|
|
277
|
+
result = _completed(ssh_args(profile, address, verbose=True))
|
|
278
|
+
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
279
|
+
offered = "offering public key" in text.lower()
|
|
280
|
+
accepted = "server accepts key" in text.lower()
|
|
281
|
+
if result.returncode == 0:
|
|
282
|
+
return {
|
|
283
|
+
"targetName": profile["targetName"],
|
|
284
|
+
"state": "verified",
|
|
285
|
+
"address": address,
|
|
286
|
+
"identityFingerprint": fingerprint,
|
|
287
|
+
"authentication": auth,
|
|
288
|
+
"offeredConfiguredIdentity": offered,
|
|
289
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
290
|
+
"mutated": False,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
attempts.append(
|
|
294
|
+
{
|
|
295
|
+
"address": address,
|
|
296
|
+
"tcp": tcp,
|
|
297
|
+
"authentication": auth,
|
|
298
|
+
"identityFingerprint": fingerprint,
|
|
299
|
+
"offeredConfiguredIdentity": offered,
|
|
300
|
+
"serverAcceptedConfiguredIdentity": accepted,
|
|
301
|
+
"returnCode": result.returncode,
|
|
302
|
+
"failureCategory": _classify_failure(
|
|
303
|
+
text,
|
|
304
|
+
auth=auth,
|
|
305
|
+
offered=offered,
|
|
306
|
+
accepted=accepted,
|
|
307
|
+
),
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
category = "transport-failed"
|
|
312
|
+
for candidate in (
|
|
313
|
+
"host-identity-rejected",
|
|
314
|
+
"local-signing-failed",
|
|
315
|
+
"remote-account-policy-rejected",
|
|
316
|
+
"remote-authentication-rejected",
|
|
317
|
+
"authentication-rejected",
|
|
318
|
+
"ssh-verification-failed",
|
|
319
|
+
):
|
|
320
|
+
if any(item.get("failureCategory") == candidate for item in attempts):
|
|
321
|
+
category = candidate
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
guidance = {
|
|
325
|
+
"transport-failed": "Verify the configured address/port and network path; do not scan or silently switch targets.",
|
|
326
|
+
"host-identity-rejected": "Re-verify host identity independently before any trust rotation.",
|
|
327
|
+
"local-signing-failed": "Repair local key/passphrase/agent use; do not weaken remote authentication policy.",
|
|
328
|
+
"remote-authentication-rejected": (
|
|
329
|
+
"The configured public-key path reached the server but authentication was rejected. "
|
|
330
|
+
"Verify authorized_keys and remote account/source access policy from a trusted console or already-authorized session."
|
|
331
|
+
),
|
|
332
|
+
"remote-account-policy-rejected": (
|
|
333
|
+
"The server accepted the configured key but the session was still rejected. "
|
|
334
|
+
"Inspect remote account/source policy from a trusted console or already-authorized session."
|
|
335
|
+
),
|
|
336
|
+
"authentication-rejected": "Inspect the server-advertised authentication policy and the configured account.",
|
|
337
|
+
"ssh-verification-failed": "Inspect the structured attempt evidence before changing target or SSH policy.",
|
|
338
|
+
}[category]
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"targetName": profile["targetName"],
|
|
342
|
+
"state": "failed",
|
|
343
|
+
"failureCategory": category,
|
|
344
|
+
"identityFingerprint": fingerprint,
|
|
345
|
+
"attempts": attempts,
|
|
346
|
+
"guidance": guidance,
|
|
347
|
+
"mutated": False,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
89
351
|
def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
90
352
|
identity = Path(profile["identityFile"])
|
|
91
353
|
known_hosts = Path(profile["knownHostsFile"])
|
|
@@ -101,15 +363,7 @@ def verify_access(profile: dict[str, Any]) -> dict[str, str]:
|
|
|
101
363
|
|
|
102
364
|
failures: list[str] = []
|
|
103
365
|
for address in resolve_addresses(profile):
|
|
104
|
-
result =
|
|
105
|
-
ssh_args(profile, address),
|
|
106
|
-
check=False,
|
|
107
|
-
capture_output=True,
|
|
108
|
-
text=True,
|
|
109
|
-
encoding="utf-8",
|
|
110
|
-
errors="replace",
|
|
111
|
-
stdin=subprocess.DEVNULL,
|
|
112
|
-
)
|
|
366
|
+
result = _completed(ssh_args(profile, address))
|
|
113
367
|
if result.returncode == 0:
|
|
114
368
|
return {"targetName": profile["targetName"], "address": address, "status": "verified"}
|
|
115
369
|
failures.append(f"{address}: exit {result.returncode}")
|