@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.
@@ -1,7 +1,8 @@
1
- """Strict SSH verification primitives for logical targets."""
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 ssh_args(profile: dict[str, Any], address: str, *, command: str = "true") -> list[str]:
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 = subprocess.run(
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}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {