keywarden-ssh 0.3.0__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.
- keywarden/__init__.py +3 -0
- keywarden/__main__.py +6 -0
- keywarden/access.py +447 -0
- keywarden/check.py +690 -0
- keywarden/cli.py +624 -0
- keywarden/collect.py +967 -0
- keywarden/controls.py +133 -0
- keywarden/evidence.py +207 -0
- keywarden/inventory.py +199 -0
- keywarden/parse.py +443 -0
- keywarden/term.py +67 -0
- keywarden/ui.py +348 -0
- keywarden/ui_lab.py +173 -0
- keywarden/ui_page.py +665 -0
- keywarden/waivers.py +171 -0
- keywarden_ssh-0.3.0.dist-info/METADATA +280 -0
- keywarden_ssh-0.3.0.dist-info/RECORD +21 -0
- keywarden_ssh-0.3.0.dist-info/WHEEL +5 -0
- keywarden_ssh-0.3.0.dist-info/entry_points.txt +2 -0
- keywarden_ssh-0.3.0.dist-info/licenses/LICENSE +21 -0
- keywarden_ssh-0.3.0.dist-info/top_level.txt +1 -0
keywarden/ui.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""keywarden ui: the same checks in a browser tab, served from this machine only.
|
|
2
|
+
|
|
3
|
+
The server listens on 127.0.0.1, answers only requests that carry the session
|
|
4
|
+
token and a localhost Host header, and keeps nothing between requests. Public
|
|
5
|
+
key text is fingerprinted on arrival and dropped; the page holds fingerprints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import date
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
from urllib.parse import parse_qs, urlsplit
|
|
21
|
+
|
|
22
|
+
from keywarden import __version__
|
|
23
|
+
from keywarden.access import key_rows
|
|
24
|
+
from keywarden.check import FINGERPRINT, HostObservation, Policy, apply_drift, evaluate
|
|
25
|
+
from keywarden.cli import (
|
|
26
|
+
_envelope,
|
|
27
|
+
_host_report,
|
|
28
|
+
_matches,
|
|
29
|
+
_observe_target,
|
|
30
|
+
_targets,
|
|
31
|
+
_unreachable,
|
|
32
|
+
csv_text,
|
|
33
|
+
prior_from_document,
|
|
34
|
+
)
|
|
35
|
+
from keywarden.collect import REMOTE_SCRIPT, ProtocolError, collect_local, collect_remote, parse_protocol
|
|
36
|
+
from keywarden.parse import KeyRef, PrivateKeyError, kind_of, parse_public_keys
|
|
37
|
+
from keywarden.ui_lab import LAB_PUSH, lab_hosts
|
|
38
|
+
from keywarden.ui_page import render_page
|
|
39
|
+
from keywarden.waivers import apply_waivers, load_waivers
|
|
40
|
+
|
|
41
|
+
MAX_BODY = 2_000_000
|
|
42
|
+
TARGETS = {"lab", "local", "host", "inventory", "paste"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BadRequest(ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _push_from(body: dict) -> list[KeyRef]:
|
|
50
|
+
"""New key text replaces the set. Without it, the fingerprints the page already holds are used."""
|
|
51
|
+
text = body.get("allowText") or ""
|
|
52
|
+
if not isinstance(text, str):
|
|
53
|
+
raise BadRequest("allowText must be text")
|
|
54
|
+
if text.strip():
|
|
55
|
+
try:
|
|
56
|
+
keys, _issues = parse_public_keys(text)
|
|
57
|
+
finally:
|
|
58
|
+
del text
|
|
59
|
+
if not keys:
|
|
60
|
+
raise BadRequest("No public keys were found in the push box.")
|
|
61
|
+
return keys
|
|
62
|
+
keys = []
|
|
63
|
+
for item in body.get("allowFingerprints") or []:
|
|
64
|
+
if not isinstance(item, dict) or not FINGERPRINT.fullmatch(str(item.get("fingerprint", ""))):
|
|
65
|
+
raise BadRequest("allowFingerprints holds something that is not a fingerprint")
|
|
66
|
+
key_type = str(item.get("type") or "unknown")
|
|
67
|
+
bits = item.get("bits") if isinstance(item.get("bits"), int) else None
|
|
68
|
+
keys.append(KeyRef(item["fingerprint"], key_type, kind_of(key_type), bits, str(item.get("comment") or "")))
|
|
69
|
+
return keys
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _policy(body: dict) -> Policy:
|
|
73
|
+
stale = body.get("staleDays", 90)
|
|
74
|
+
if not isinstance(stale, int) or stale < 1 or stale > 3650:
|
|
75
|
+
raise BadRequest("Stale after must be between 1 and 3650 days.")
|
|
76
|
+
return Policy(
|
|
77
|
+
flag_outside_push=bool(body.get("flagOutside", True)),
|
|
78
|
+
flag_weak=bool(body.get("flagWeak", True)),
|
|
79
|
+
stale_days=stale,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _args(body: dict) -> SimpleNamespace:
|
|
84
|
+
accounts = body.get("accounts") or []
|
|
85
|
+
if not isinstance(accounts, list) or not all(isinstance(item, str) for item in accounts):
|
|
86
|
+
raise BadRequest("accounts must be a list of names")
|
|
87
|
+
return SimpleNamespace(account=accounts, accept_new=bool(body.get("acceptNew", False)))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _observations(body: dict):
|
|
91
|
+
"""Yield (name, observation or error dict, role, prior) for the chosen target."""
|
|
92
|
+
target = body.get("target")
|
|
93
|
+
if target not in TARGETS:
|
|
94
|
+
raise BadRequest("Pick what to check.")
|
|
95
|
+
args = _args(body)
|
|
96
|
+
if target == "lab":
|
|
97
|
+
for observation, address, role, prior in lab_hosts(int(time.time())):
|
|
98
|
+
observation.address = address
|
|
99
|
+
yield observation.name, observation, role, prior
|
|
100
|
+
return
|
|
101
|
+
if target == "local":
|
|
102
|
+
observation = collect_local(args.account)
|
|
103
|
+
yield observation.name, observation, "This machine", None
|
|
104
|
+
return
|
|
105
|
+
if target == "host":
|
|
106
|
+
destination = str(body.get("host") or "").strip()
|
|
107
|
+
if not destination:
|
|
108
|
+
raise BadRequest("Enter a host as user@hostname.")
|
|
109
|
+
try:
|
|
110
|
+
observation = collect_remote(destination, args.account, accept_new=args.accept_new)
|
|
111
|
+
except ProtocolError as exc:
|
|
112
|
+
yield destination, _unreachable(destination, destination, str(exc), code="collector-output"), "", None
|
|
113
|
+
return
|
|
114
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
115
|
+
yield destination, _unreachable(destination, destination, str(exc)), "", None
|
|
116
|
+
return
|
|
117
|
+
yield observation.name, observation, "", None
|
|
118
|
+
return
|
|
119
|
+
if target == "paste":
|
|
120
|
+
text = str(body.get("observation") or "")
|
|
121
|
+
if not text.strip():
|
|
122
|
+
raise BadRequest("Paste the collector output first.")
|
|
123
|
+
if len(text) > 1_000_000:
|
|
124
|
+
raise BadRequest("The pasted output is larger than 1 MB.")
|
|
125
|
+
try:
|
|
126
|
+
observation = parse_protocol(text, address="pasted")
|
|
127
|
+
finally:
|
|
128
|
+
del text
|
|
129
|
+
yield observation.name, observation, "Pasted", None
|
|
130
|
+
return
|
|
131
|
+
path = Path(str(body.get("inventoryPath") or "").strip()).expanduser()
|
|
132
|
+
if not str(path) or not path.is_file():
|
|
133
|
+
raise BadRequest("Enter the path to an INI inventory on this machine.")
|
|
134
|
+
for target_host in _targets(path):
|
|
135
|
+
try:
|
|
136
|
+
observation = _observe_target(target_host, args)
|
|
137
|
+
except ProtocolError as exc:
|
|
138
|
+
yield target_host.name, _unreachable(target_host.name, target_host.destination, str(exc), code="collector-output"), "", None
|
|
139
|
+
continue
|
|
140
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
141
|
+
yield target_host.name, _unreachable(target_host.name, target_host.destination, str(exc)), "", None
|
|
142
|
+
continue
|
|
143
|
+
yield target_host.name, observation, "", None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_check(body: dict) -> dict:
|
|
147
|
+
push = _push_from(body)
|
|
148
|
+
policy = _policy(body)
|
|
149
|
+
waivers = []
|
|
150
|
+
waiver_path = str(body.get("waiversPath") or "").strip()
|
|
151
|
+
if waiver_path:
|
|
152
|
+
waivers = load_waivers(Path(waiver_path).expanduser())
|
|
153
|
+
uploaded_prior = body.get("prior")
|
|
154
|
+
used: set[int] = set()
|
|
155
|
+
hosts: list[dict] = []
|
|
156
|
+
rows: list[dict] = []
|
|
157
|
+
for name, observed, role, lab_prior in _observations(body):
|
|
158
|
+
if not isinstance(observed, HostObservation):
|
|
159
|
+
observed["roleLabel"] = role
|
|
160
|
+
hosts.append(observed)
|
|
161
|
+
continue
|
|
162
|
+
prior = None
|
|
163
|
+
if lab_prior is not None:
|
|
164
|
+
# The lab's previous run: today's results, except where it says a check was different.
|
|
165
|
+
prior = {item.code: (item.severity, set(item.fingerprints)) for item in evaluate(observed, push, policy)}
|
|
166
|
+
prior.update(lab_prior)
|
|
167
|
+
if uploaded_prior is not None:
|
|
168
|
+
prior = prior_from_document(uploaded_prior, observed.name) or prior
|
|
169
|
+
findings = apply_drift(evaluate(observed, push, policy), prior)
|
|
170
|
+
findings = apply_waivers(findings, observed.name, waivers, date.today(), used)
|
|
171
|
+
host_rows = key_rows(observed, push, policy)
|
|
172
|
+
rows.extend(host_rows)
|
|
173
|
+
report = _host_report(observed, findings, host_rows)
|
|
174
|
+
report["roleLabel"] = role
|
|
175
|
+
hosts.append(report)
|
|
176
|
+
document = _envelope(push)
|
|
177
|
+
document["hosts"] = hosts
|
|
178
|
+
document["posture"] = "fail" if any(host["posture"] == "fail" for host in hosts) else "pass"
|
|
179
|
+
notes = [
|
|
180
|
+
f"Waiver {waiver.label} ({waiver.code}) matched nothing; remove it if the risk is gone."
|
|
181
|
+
for waiver in waivers
|
|
182
|
+
if waiver.index not in used
|
|
183
|
+
]
|
|
184
|
+
from keywarden.evidence import render_evidence
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"report": document,
|
|
188
|
+
"csv": csv_text(rows),
|
|
189
|
+
"evidence": render_evidence(document),
|
|
190
|
+
"notes": notes,
|
|
191
|
+
"lab": body.get("target") == "lab",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_where(body: dict) -> dict:
|
|
196
|
+
wanted: list[str] = []
|
|
197
|
+
for value in body.get("fingerprints") or []:
|
|
198
|
+
value = str(value).strip()
|
|
199
|
+
if value:
|
|
200
|
+
if not FINGERPRINT.fullmatch(value):
|
|
201
|
+
raise BadRequest(f"{value[:60]} is not a fingerprint. Use SHA256:..., as ssh-keygen -lf prints.")
|
|
202
|
+
wanted.append(value)
|
|
203
|
+
text = str(body.get("keyText") or "")
|
|
204
|
+
if text.strip():
|
|
205
|
+
try:
|
|
206
|
+
keys, _issues = parse_public_keys(text)
|
|
207
|
+
finally:
|
|
208
|
+
del text
|
|
209
|
+
wanted.extend(key.fingerprint for key in keys)
|
|
210
|
+
if not wanted:
|
|
211
|
+
raise BadRequest("Enter a fingerprint or paste a public key.")
|
|
212
|
+
found: list[dict] = []
|
|
213
|
+
blind: list[str] = []
|
|
214
|
+
for name, observed, _role, _prior in _observations(body):
|
|
215
|
+
if not isinstance(observed, HostObservation):
|
|
216
|
+
blind.append(f"{name}: not checked ({observed['findings'][0]['detail'][:120]})")
|
|
217
|
+
continue
|
|
218
|
+
found.extend(_matches(observed, set(wanted)))
|
|
219
|
+
blind.extend(f"{observed.name}: {account.user} could not be read" for account in observed.accounts if account.read_state == "unreadable")
|
|
220
|
+
return {"fingerprints": list(dict.fromkeys(wanted)), "found": found, "notChecked": blind}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Handler(BaseHTTPRequestHandler):
|
|
224
|
+
server_version = "keywarden-ui"
|
|
225
|
+
sys_version = ""
|
|
226
|
+
|
|
227
|
+
def log_message(self, format, *args): # noqa: A002 - quiet: requests can carry host names
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Every response: no caching, no framing, no referrer, scripts only with this page's nonce.
|
|
231
|
+
def _headers(self, status: int, content_type: str, length: int, nonce: str | None = None) -> None:
|
|
232
|
+
self.send_response(status)
|
|
233
|
+
self.send_header("Content-Type", content_type)
|
|
234
|
+
self.send_header("Content-Length", str(length))
|
|
235
|
+
self.send_header("Cache-Control", "no-store")
|
|
236
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
237
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
238
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
239
|
+
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
|
240
|
+
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
|
|
241
|
+
if nonce:
|
|
242
|
+
self.send_header(
|
|
243
|
+
"Content-Security-Policy",
|
|
244
|
+
f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; "
|
|
245
|
+
"img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
246
|
+
)
|
|
247
|
+
self.end_headers()
|
|
248
|
+
|
|
249
|
+
def _send_json(self, status: int, payload: dict) -> None:
|
|
250
|
+
data = json.dumps(payload).encode("utf-8")
|
|
251
|
+
self._headers(status, "application/json; charset=utf-8", len(data))
|
|
252
|
+
self.wfile.write(data)
|
|
253
|
+
|
|
254
|
+
def _host_ok(self) -> bool:
|
|
255
|
+
port = self.server.server_address[1]
|
|
256
|
+
return self.headers.get("Host", "") in {f"127.0.0.1:{port}", f"localhost:{port}"}
|
|
257
|
+
|
|
258
|
+
def _origin_ok(self) -> bool:
|
|
259
|
+
origin = self.headers.get("Origin")
|
|
260
|
+
if origin is None:
|
|
261
|
+
return True
|
|
262
|
+
port = self.server.server_address[1]
|
|
263
|
+
return origin in {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}
|
|
264
|
+
|
|
265
|
+
def _token_ok(self, supplied: str | None) -> bool:
|
|
266
|
+
return supplied is not None and secrets.compare_digest(supplied, self.server.token)
|
|
267
|
+
|
|
268
|
+
def do_GET(self): # noqa: N802
|
|
269
|
+
if not self._host_ok():
|
|
270
|
+
return self._send_json(421, {"error": "wrong host"})
|
|
271
|
+
url = urlsplit(self.path)
|
|
272
|
+
if url.path == "/":
|
|
273
|
+
token = parse_qs(url.query).get("t", [None])[0]
|
|
274
|
+
if not self._token_ok(token):
|
|
275
|
+
body = b"Open the link keywarden ui printed. It carries this session's token."
|
|
276
|
+
self._headers(403, "text/plain; charset=utf-8", len(body))
|
|
277
|
+
return self.wfile.write(body)
|
|
278
|
+
nonce = secrets.token_urlsafe(16)
|
|
279
|
+
page = render_page(nonce, self.server.token, __version__).encode("utf-8")
|
|
280
|
+
self._headers(200, "text/html; charset=utf-8", len(page), nonce)
|
|
281
|
+
return self.wfile.write(page)
|
|
282
|
+
return self._send_json(404, {"error": "not found"})
|
|
283
|
+
|
|
284
|
+
def do_POST(self): # noqa: N802
|
|
285
|
+
if not self._host_ok() or not self._origin_ok():
|
|
286
|
+
return self._send_json(403, {"error": "This server only answers its own page."})
|
|
287
|
+
if not self._token_ok(self.headers.get("X-Keywarden-Token")):
|
|
288
|
+
return self._send_json(403, {"error": "Missing or wrong session token. Reopen the link keywarden ui printed."})
|
|
289
|
+
if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
|
|
290
|
+
return self._send_json(415, {"error": "Send JSON."})
|
|
291
|
+
try:
|
|
292
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
293
|
+
except ValueError:
|
|
294
|
+
length = -1
|
|
295
|
+
if length < 0 or length > MAX_BODY:
|
|
296
|
+
return self._send_json(413, {"error": "The request is larger than 2 MB."})
|
|
297
|
+
raw = self.rfile.read(length)
|
|
298
|
+
try:
|
|
299
|
+
body = json.loads(raw.decode("utf-8"))
|
|
300
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
301
|
+
return self._send_json(400, {"error": "The request was not valid JSON."})
|
|
302
|
+
finally:
|
|
303
|
+
del raw
|
|
304
|
+
if not isinstance(body, dict):
|
|
305
|
+
return self._send_json(400, {"error": "The request was not a JSON object."})
|
|
306
|
+
try:
|
|
307
|
+
if self.path == "/api/check":
|
|
308
|
+
return self._send_json(200, run_check(body))
|
|
309
|
+
if self.path == "/api/where":
|
|
310
|
+
return self._send_json(200, run_where(body))
|
|
311
|
+
if self.path == "/api/collector":
|
|
312
|
+
return self._send_json(200, {"script": REMOTE_SCRIPT})
|
|
313
|
+
if self.path == "/api/lab-keys":
|
|
314
|
+
return self._send_json(200, {"keys": LAB_PUSH})
|
|
315
|
+
return self._send_json(404, {"error": "not found"})
|
|
316
|
+
except PrivateKeyError:
|
|
317
|
+
return self._send_json(400, {"error": "A private key was in the input. Keywarden takes public keys only and kept nothing."})
|
|
318
|
+
except (BadRequest, ValueError, OSError, RuntimeError, json.JSONDecodeError) as exc:
|
|
319
|
+
return self._send_json(400, {"error": str(exc)[:400]})
|
|
320
|
+
finally:
|
|
321
|
+
body.clear()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class KeywardenServer(ThreadingHTTPServer):
|
|
325
|
+
daemon_threads = True
|
|
326
|
+
|
|
327
|
+
def __init__(self, port: int):
|
|
328
|
+
super().__init__(("127.0.0.1", port), Handler)
|
|
329
|
+
self.token = secrets.token_urlsafe(24)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def serve(port: int = 0, open_browser: bool = True) -> int:
|
|
333
|
+
server = KeywardenServer(port)
|
|
334
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/?t={server.token}"
|
|
335
|
+
print(f"Keywarden is running at {url}")
|
|
336
|
+
print("Only this machine can reach it. Press Ctrl+C to stop; nothing is kept when it stops.")
|
|
337
|
+
if open_browser:
|
|
338
|
+
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
|
|
339
|
+
try:
|
|
340
|
+
server.serve_forever()
|
|
341
|
+
except KeyboardInterrupt:
|
|
342
|
+
print("\nStopped.", file=sys.stderr)
|
|
343
|
+
finally:
|
|
344
|
+
server.server_close()
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
__all__ = ["serve", "run_check", "run_where"]
|
keywarden/ui_lab.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The lab: six made-up hosts for trying the interface. None of these keys exist anywhere real."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from keywarden.check import HostObservation
|
|
6
|
+
from keywarden.collect import parse_protocol
|
|
7
|
+
from keywarden.parse import parse_public_keys
|
|
8
|
+
|
|
9
|
+
DAY = 86400
|
|
10
|
+
|
|
11
|
+
LAB_KEYS = {
|
|
12
|
+
"ops": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMw3CRyIVJdlcMu0yNey4yKK9cbarwPgFyt3RYj11nOm ops@latch",
|
|
13
|
+
"deploy": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFNC5kulzZA+Y15VIvsXBCD4xVqmzgNc3Yrt0uMZrLMe deploy@latch",
|
|
14
|
+
"ci": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKyqTebqUn7JMI3f6e7CRqxw/LVNetdp5tfDuNCSA9hi ci-bot",
|
|
15
|
+
"root": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMjKhLYgU3zJepCV0ElII061TiD8YX9qsBP9hfJjhB6v root@app-web-03",
|
|
16
|
+
"contractor": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAFuDUY/c8R+5oUuOUyByXj9ZGOVT/nVX5lnf+RXtZwI contractor@temp",
|
|
17
|
+
"alice": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILamNR3cDT6qyuPVk9beyF8PHD/KXGgtPGuYeqOp4Tmz alice@old-laptop",
|
|
18
|
+
"legacy": (
|
|
19
|
+
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDM6KTe5dS730B8qyUwp1YK6JM8ccsTIrDRedOsdrz0eF+058iL/B21uIPEE+5/ffdY+DXN0so0IjckwZvVm3SkdFu3"
|
|
20
|
+
"KnVShlUXIlyvjtHWKohCpK6ZiNf33OvpPBePbciFUrcFT9XeclgqziKoMUzeCgsSJ89sOkXvUTBfBNkq3JkK7Lges129q/D0XpFduFjAagdG10jOGBbR5OyduljMYJVjL3bn"
|
|
21
|
+
"88GaaNsPTkYoZsEZ9ttawoIi4sJTNLQYJBcXjmrviQNjIjVLDzsnhXArXQd/oJnXl2ZxVXIoQxKucHlChbLQDIJ8MT61hB2X4ee+ltwAwK3lRHRSynzLyfyh legacy-jump"
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
LAB_PUSH = [LAB_KEYS["ops"], LAB_KEYS["deploy"], LAB_KEYS["ci"]]
|
|
25
|
+
|
|
26
|
+
_SAFE_SSHD = {
|
|
27
|
+
"permitrootlogin": "no",
|
|
28
|
+
"passwordauthentication": "no",
|
|
29
|
+
"kbdinteractiveauthentication": "no",
|
|
30
|
+
"pubkeyauthentication": "yes",
|
|
31
|
+
"permitemptypasswords": "no",
|
|
32
|
+
"authenticationmethods": "publickey",
|
|
33
|
+
"hostbasedauthentication": "no",
|
|
34
|
+
"pubkeyacceptedalgorithms": "ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
|
|
35
|
+
"casignaturealgorithms": "ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
|
|
36
|
+
"trustedusercakeys": "none",
|
|
37
|
+
"revokedkeys": "none",
|
|
38
|
+
"authorizedkeyscommand": "none",
|
|
39
|
+
"authorizedkeysfile": ".ssh/authorized_keys .ssh/authorized_keys2",
|
|
40
|
+
"loglevel": "INFO",
|
|
41
|
+
"allowtcpforwarding": "yes",
|
|
42
|
+
"allowagentforwarding": "yes",
|
|
43
|
+
"x11forwarding": "no",
|
|
44
|
+
"permituserenvironment": "no",
|
|
45
|
+
}
|
|
46
|
+
_ADMIN_SUDO = ["User {user} may run the following commands on {host}:", " (ALL : ALL) NOPASSWD: ALL"]
|
|
47
|
+
_NO_SUDO = ["User {user} is not allowed to run sudo on {host}."]
|
|
48
|
+
_PASSWORD_SUDO = ["User {user} may run the following commands on {host}:", " (ALL : ALL) ALL"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fingerprint(line: str) -> str:
|
|
52
|
+
return parse_public_keys(line)[0][0].fingerprint
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _account(user, uid, groups, sudo, keys, host, mode="600"):
|
|
56
|
+
lines = [f"ACCOUNT {user}", f"UID {uid}", "LOCKED yes", f"GROUPS {' '.join(groups)}", "SUDOSTATE ok", "SUDO"]
|
|
57
|
+
lines += ["S " + line.format(user=user, host=host) for line in sudo]
|
|
58
|
+
lines += ["ENDSUDO"]
|
|
59
|
+
if not keys:
|
|
60
|
+
return lines + ["DIR 700", "FILE absent", "STATE absent", "KEYS", "ENDKEYS"]
|
|
61
|
+
lines += ["DIR 700", f"FILE {mode}", "STATE ok", "KEYS"]
|
|
62
|
+
lines += ["K " + key for key in keys]
|
|
63
|
+
return lines + ["ENDKEYS"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _host(name, now, sshd_changes, accounts, logins):
|
|
67
|
+
sshd = dict(_SAFE_SSHD)
|
|
68
|
+
sshd.update(sshd_changes)
|
|
69
|
+
lines = [
|
|
70
|
+
"KEYWARDEN 3",
|
|
71
|
+
f"HOST {name}",
|
|
72
|
+
f"NOW {now} +0000",
|
|
73
|
+
"PRIV sudo",
|
|
74
|
+
"SSHDRUN ok",
|
|
75
|
+
"SSHD",
|
|
76
|
+
*(f"{key} {value}" for key, value in sshd.items()),
|
|
77
|
+
"ENDSSHD",
|
|
78
|
+
"MATCH no",
|
|
79
|
+
"SSHDROOT",
|
|
80
|
+
"ENDSSHDROOT",
|
|
81
|
+
"AUTHLOG journal",
|
|
82
|
+
"LOGINS",
|
|
83
|
+
f"AUTHFIRST {now - 210 * DAY}.000000",
|
|
84
|
+
]
|
|
85
|
+
for user, key, days_ago, source, count in logins:
|
|
86
|
+
lines.append(
|
|
87
|
+
f"L {count} {now - days_ago * DAY}.000000 {name} sshd[1]: Accepted publickey for {user} "
|
|
88
|
+
f"from {source} port 50000 ssh2: ED25519 {_fingerprint(LAB_KEYS[key])}"
|
|
89
|
+
)
|
|
90
|
+
lines.append("ENDLOGINS")
|
|
91
|
+
for account in accounts:
|
|
92
|
+
lines += account
|
|
93
|
+
lines += ["PASSWD ok", "END"]
|
|
94
|
+
return "\n".join(lines) + "\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def lab_hosts(now: int) -> list[tuple[HostObservation, str, str, dict]]:
|
|
98
|
+
"""(observation, address, role, prior changes) for each lab host.
|
|
99
|
+
|
|
100
|
+
The prior changes are how the previous run differed from this one; every other check is unchanged.
|
|
101
|
+
"""
|
|
102
|
+
k = LAB_KEYS
|
|
103
|
+
root_none = lambda host: _account("root", 0, ["root"], [], [], host) # noqa: E731
|
|
104
|
+
specs = [
|
|
105
|
+
(
|
|
106
|
+
"app-web-03", "10.0.4.23", "Application",
|
|
107
|
+
{"permitrootlogin": "prohibit-password"},
|
|
108
|
+
lambda h: [
|
|
109
|
+
_account("root", 0, ["root"], [], [k["root"]], h),
|
|
110
|
+
_account("ops", 1000, ["ops", "sudo"], _ADMIN_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
|
|
111
|
+
_account("deploy", 1001, ["deploy"], _NO_SUDO, [k["deploy"]], h),
|
|
112
|
+
],
|
|
113
|
+
[("ops", "ops", 1, "10.0.0.9", 41), ("deploy", "deploy", 0, "10.0.6.15", 212)],
|
|
114
|
+
{"permit-root": ("pass", set()), "root-authorized-key": ("pass", set())},
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
"db-primary", "10.0.8.4", "Database",
|
|
118
|
+
{"passwordauthentication": "yes"},
|
|
119
|
+
lambda h: [
|
|
120
|
+
root_none(h),
|
|
121
|
+
_account("ops", 1000, ["ops", "sudo"], _ADMIN_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
|
|
122
|
+
_account("postgres", 113, ["postgres"], _NO_SUDO, [k["contractor"]], h),
|
|
123
|
+
],
|
|
124
|
+
[("ops", "ops", 3, "10.0.0.9", 12)],
|
|
125
|
+
{"password-auth": ("fail", set()), "outside-push": ("fail", {_fingerprint(k["contractor"])})},
|
|
126
|
+
),
|
|
127
|
+
(
|
|
128
|
+
"ci-runner", "10.0.6.15", "Automation",
|
|
129
|
+
{},
|
|
130
|
+
lambda h: [
|
|
131
|
+
root_none(h),
|
|
132
|
+
_account("deploy", 1001, ["deploy", "docker"], _NO_SUDO, [k["ci"], k["alice"]], h),
|
|
133
|
+
],
|
|
134
|
+
[("deploy", "ci", 0, "140.82.112.4", 388), ("deploy", "alice", 151, "10.20.0.14", 3)],
|
|
135
|
+
{"outside-push": ("pass", set()), "stale-key": ("pass", set())},
|
|
136
|
+
),
|
|
137
|
+
(
|
|
138
|
+
"jump-legacy", "10.0.1.4", "Legacy jump",
|
|
139
|
+
{"pubkeyacceptedalgorithms": "ssh-ed25519,rsa-sha2-256,ssh-rsa"},
|
|
140
|
+
lambda h: [
|
|
141
|
+
root_none(h),
|
|
142
|
+
_account("ops", 1000, ["ops"], _NO_SUDO, ['from="10.0.0.0/16" ' + k["ops"], k["legacy"]], h),
|
|
143
|
+
],
|
|
144
|
+
[("ops", "ops", 2, "10.0.0.9", 30)],
|
|
145
|
+
{"weak-algorithm": ("fail", {_fingerprint(k["legacy"])}), "accepted-algorithms": ("fail", set())},
|
|
146
|
+
),
|
|
147
|
+
(
|
|
148
|
+
"bastion", "10.0.0.9", "Bastion",
|
|
149
|
+
{"allowtcpforwarding": "no", "allowagentforwarding": "no"},
|
|
150
|
+
lambda h: [
|
|
151
|
+
root_none(h),
|
|
152
|
+
_account("ops", 1000, ["ops"], _NO_SUDO, [k["ops"]], h),
|
|
153
|
+
],
|
|
154
|
+
[("ops", "ops", 0, "203.0.113.20", 96)],
|
|
155
|
+
{},
|
|
156
|
+
),
|
|
157
|
+
(
|
|
158
|
+
"mon-01", "10.0.9.7", "Monitoring",
|
|
159
|
+
{},
|
|
160
|
+
lambda h: [
|
|
161
|
+
root_none(h),
|
|
162
|
+
_account("ops", 1000, ["ops", "sudo"], _PASSWORD_SUDO, ['from="10.0.0.0/16" ' + k["ops"]], h),
|
|
163
|
+
_account("deploy", 1001, ["deploy"], _NO_SUDO, ['restrict,command="/usr/local/bin/collect" ' + k["deploy"]], h),
|
|
164
|
+
],
|
|
165
|
+
[("ops", "ops", 6, "10.0.0.9", 8), ("deploy", "deploy", 0, "10.0.6.15", 1440)],
|
|
166
|
+
{},
|
|
167
|
+
),
|
|
168
|
+
]
|
|
169
|
+
result = []
|
|
170
|
+
for name, address, role, sshd, accounts, logins, prior in specs:
|
|
171
|
+
observation = parse_protocol(_host(name, now, sshd, accounts(name), logins), address=address)
|
|
172
|
+
result.append((observation, address, role, prior))
|
|
173
|
+
return result
|