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/cli.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Command line. Reports contain fingerprints only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import dataclasses
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from keywarden import __version__
|
|
15
|
+
from keywarden.access import key_rows, reach_of
|
|
16
|
+
from keywarden.check import FINGERPRINT, HostObservation, Policy, PriorFindings, apply_drift, evaluate, worst
|
|
17
|
+
from keywarden.collect import ProtocolError, collect_local, collect_remote, now_iso, parse_protocol, read_observation
|
|
18
|
+
from keywarden.inventory import InventoryHost, parse_inventory
|
|
19
|
+
from keywarden.parse import PrivateKeyError, parse_public_keys
|
|
20
|
+
from keywarden.term import Paint, use_colour
|
|
21
|
+
from keywarden.waivers import Waiver, apply_waivers, load_waivers
|
|
22
|
+
|
|
23
|
+
MAX_INPUT_BYTES = 1_000_000
|
|
24
|
+
MAX_INVENTORY_HOSTS = 256
|
|
25
|
+
_FINGERPRINT = FINGERPRINT
|
|
26
|
+
CSV_COLUMNS = [
|
|
27
|
+
"host",
|
|
28
|
+
"account",
|
|
29
|
+
"uid",
|
|
30
|
+
"fingerprint",
|
|
31
|
+
"type",
|
|
32
|
+
"bits",
|
|
33
|
+
"comment",
|
|
34
|
+
"inAllow",
|
|
35
|
+
"reach",
|
|
36
|
+
"reachVia",
|
|
37
|
+
"from",
|
|
38
|
+
"command",
|
|
39
|
+
"allows",
|
|
40
|
+
"certAuthority",
|
|
41
|
+
"usage",
|
|
42
|
+
"lastUsed",
|
|
43
|
+
"uses",
|
|
44
|
+
"lastFrom",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_allow(path: Path | None):
|
|
49
|
+
if path is None:
|
|
50
|
+
return []
|
|
51
|
+
if path.stat().st_size > MAX_INPUT_BYTES:
|
|
52
|
+
raise ValueError("--allow file is larger than 1 MB")
|
|
53
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
54
|
+
try:
|
|
55
|
+
keys, issues = parse_public_keys(text)
|
|
56
|
+
finally:
|
|
57
|
+
del text
|
|
58
|
+
if issues:
|
|
59
|
+
print(f"{len(issues)} line(s) in --allow were not public keys and were ignored", file=sys.stderr)
|
|
60
|
+
return keys
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_prior(path: Path | None, hostname: str) -> PriorFindings | None:
|
|
64
|
+
if path is None:
|
|
65
|
+
return None
|
|
66
|
+
return prior_from_document(json.loads(path.read_text(encoding="utf-8")), hostname)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prior_from_document(document, hostname: str) -> PriorFindings | None:
|
|
70
|
+
host = document.get("host") if isinstance(document, dict) else None
|
|
71
|
+
if not isinstance(host, dict):
|
|
72
|
+
print("prior file has no host report; drift skipped", file=sys.stderr)
|
|
73
|
+
return None
|
|
74
|
+
name = host.get("name")
|
|
75
|
+
if name and name != hostname:
|
|
76
|
+
print(f"prior report is for {name}, not {hostname}; drift skipped", file=sys.stderr)
|
|
77
|
+
return None
|
|
78
|
+
prior: PriorFindings = {}
|
|
79
|
+
for item in host.get("findings") or []:
|
|
80
|
+
if not isinstance(item, dict) or "code" not in item:
|
|
81
|
+
continue
|
|
82
|
+
prints = item.get("fingerprints")
|
|
83
|
+
if not isinstance(prints, list):
|
|
84
|
+
# Reports from 0.1.0 have no fingerprints array; read them from the detail.
|
|
85
|
+
prints = _FINGERPRINT.findall(str(item.get("detail") or ""))
|
|
86
|
+
severity = str(item.get("was") or item.get("severity"))
|
|
87
|
+
prior[item["code"]] = (severity, {str(value) for value in prints})
|
|
88
|
+
return prior
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _redact(host: HostObservation, push: list) -> list:
|
|
92
|
+
"""Drop key comments. They are often an email address or a laptop name."""
|
|
93
|
+
for account in host.accounts:
|
|
94
|
+
account.keys = [dataclasses.replace(key, comment="") for key in account.keys]
|
|
95
|
+
return [dataclasses.replace(key, comment="") for key in push]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _rows(host: HostObservation, push: list, policy: Policy, args: argparse.Namespace) -> list[dict]:
|
|
99
|
+
rows = key_rows(host, push, policy)
|
|
100
|
+
if getattr(args, "redact_addresses", False):
|
|
101
|
+
for row in rows:
|
|
102
|
+
row["lastFrom"] = None
|
|
103
|
+
return rows
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _host_report(host: HostObservation, findings, rows: list[dict]) -> dict:
|
|
107
|
+
return {
|
|
108
|
+
"name": host.name,
|
|
109
|
+
"address": host.address,
|
|
110
|
+
"role": host.role,
|
|
111
|
+
"observedAt": host.observed_at,
|
|
112
|
+
"privilege": host.privilege,
|
|
113
|
+
"sshdRead": host.sshd_state,
|
|
114
|
+
"matchBlocks": host.match_blocks,
|
|
115
|
+
"authLog": host.log_source,
|
|
116
|
+
"posture": worst(findings),
|
|
117
|
+
"sshd": host.sshd.as_dict(),
|
|
118
|
+
"findings": [item.as_dict() for item in findings],
|
|
119
|
+
"keys": rows,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _envelope(push: list) -> dict:
|
|
124
|
+
return {
|
|
125
|
+
"product": "keywarden",
|
|
126
|
+
"version": __version__,
|
|
127
|
+
"keyMaterial": "omitted",
|
|
128
|
+
"note": "Public key bodies are not included. Fingerprints identify keys.",
|
|
129
|
+
"generatedAt": now_iso(),
|
|
130
|
+
"push": [key.public() for key in push],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_ORDER = {"fail": 0, "warn": 1, "accepted": 2, "pass": 3}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _summary(findings: list[dict]) -> str:
|
|
138
|
+
counts = {name: sum(1 for item in findings if item["severity"] == name) for name in ("fail", "warn", "accepted", "pass")}
|
|
139
|
+
parts = [f"{counts['fail']} fail", f"{counts['warn']} warn"]
|
|
140
|
+
if counts["accepted"]:
|
|
141
|
+
parts.append(f"{counts['accepted']} accepted")
|
|
142
|
+
parts.append(f"{counts['pass']} pass")
|
|
143
|
+
return " · ".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _print_human(document: dict, paint: Paint | None = None, verbose: bool = False) -> None:
|
|
147
|
+
paint = paint or Paint(False)
|
|
148
|
+
host = document["host"]
|
|
149
|
+
findings = sorted(host["findings"], key=lambda item: _ORDER.get(item["severity"], 4))
|
|
150
|
+
posture = host["posture"]
|
|
151
|
+
print(f"{paint('bold', 'keywarden ' + document['version'])} {paint('bold', host['name'])} {paint(posture, posture.upper())} {paint('dim', _summary(findings))}")
|
|
152
|
+
shown = findings if verbose else [item for item in findings if item["severity"] != "pass"]
|
|
153
|
+
for item in shown:
|
|
154
|
+
drift = " " + paint("warn", item["drift"]) if item.get("drift") else ""
|
|
155
|
+
print(f" {paint.severity(item['severity'])} {paint('dim', item['code'])}{drift} {item['title']}")
|
|
156
|
+
print(f" {paint.fingerprints(item['detail'])}")
|
|
157
|
+
if item.get("newFingerprints"):
|
|
158
|
+
print(f" new since the prior report: {paint.fingerprints(' '.join(item['newFingerprints']))}")
|
|
159
|
+
hidden = len(findings) - len(shown)
|
|
160
|
+
if hidden:
|
|
161
|
+
print(paint("dim", f" {hidden} passing check{'s' if hidden != 1 else ''} hidden; --verbose shows them"))
|
|
162
|
+
print(paint("dim", "key material omitted"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _print_keys(rows: list[dict]) -> None:
|
|
166
|
+
if not rows:
|
|
167
|
+
print("no authorized keys were readable")
|
|
168
|
+
return
|
|
169
|
+
header = ["host", "account", "fingerprint", "reach", "usage", "last used", "from", "comment"]
|
|
170
|
+
table = [header]
|
|
171
|
+
for row in rows:
|
|
172
|
+
table.append(
|
|
173
|
+
[
|
|
174
|
+
row["host"],
|
|
175
|
+
row["account"],
|
|
176
|
+
row["fingerprint"],
|
|
177
|
+
row["reach"],
|
|
178
|
+
row["usage"],
|
|
179
|
+
(row["lastUsed"] or "-")[:10],
|
|
180
|
+
",".join(row["options"].get("from", [])) or "any",
|
|
181
|
+
row["comment"] or "-",
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
widths = [max(len(str(line[column])) for line in table) for column in range(len(header))]
|
|
185
|
+
for line in table:
|
|
186
|
+
print(" ".join(str(value).ljust(widths[column]) for column, value in enumerate(line)).rstrip())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _write_evidence(path: Path, document: dict) -> None:
|
|
190
|
+
from keywarden.evidence import render_evidence
|
|
191
|
+
|
|
192
|
+
path.write_text(render_evidence(document), encoding="utf-8")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _paint(args: argparse.Namespace) -> Paint:
|
|
196
|
+
return Paint(use_colour(getattr(args, "color", "auto")))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _write_csv(path: Path, rows: list[dict]) -> None:
|
|
200
|
+
# Path.write_text has no newline argument before Python 3.10; csv already ends rows with \r\n.
|
|
201
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
202
|
+
handle.write(csv_text(rows))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def csv_text(rows: list[dict]) -> str:
|
|
206
|
+
handle = io.StringIO()
|
|
207
|
+
writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS, extrasaction="ignore")
|
|
208
|
+
writer.writeheader()
|
|
209
|
+
for row in rows:
|
|
210
|
+
options = row["options"]
|
|
211
|
+
flat = dict(row)
|
|
212
|
+
flat["from"] = " ".join(options.get("from", []))
|
|
213
|
+
flat["command"] = "yes" if options.get("command") else ""
|
|
214
|
+
flat["allows"] = " ".join(options.get("allows", []))
|
|
215
|
+
flat["inAllow"] = "" if row["inAllow"] is None else ("yes" if row["inAllow"] else "no")
|
|
216
|
+
flat["certAuthority"] = "yes" if row["certAuthority"] else ""
|
|
217
|
+
writer.writerow({key: _csv_safe(value) for key, value in flat.items()})
|
|
218
|
+
return handle.getvalue()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _csv_safe(value):
|
|
222
|
+
"""Stop a comment like =HYPERLINK(...) from running as a formula in a spreadsheet."""
|
|
223
|
+
if isinstance(value, str) and value[:1] in {"=", "+", "-", "@", "\t", "\r"}:
|
|
224
|
+
return "'" + value
|
|
225
|
+
return "" if value is None else value
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _remember_failure(found: dict[str, dict[str, list[str]]], plain: list[str], name: str, findings: list[dict]) -> None:
|
|
229
|
+
for item in findings:
|
|
230
|
+
if item.get("severity") != "fail":
|
|
231
|
+
continue
|
|
232
|
+
fingerprints = list(dict.fromkeys(_FINGERPRINT.findall(item.get("detail") or "")))
|
|
233
|
+
if not fingerprints:
|
|
234
|
+
plain.append(f"{name} {item['code']}")
|
|
235
|
+
continue
|
|
236
|
+
for fingerprint in fingerprints:
|
|
237
|
+
bucket = found.setdefault(fingerprint, {"codes": [], "hosts": []})
|
|
238
|
+
if item["code"] not in bucket["codes"]:
|
|
239
|
+
bucket["codes"].append(item["code"])
|
|
240
|
+
if name not in bucket["hosts"]:
|
|
241
|
+
bucket["hosts"].append(name)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _unique_failure_lines(found: dict[str, dict[str, list[str]]], plain: list[str]) -> list[str]:
|
|
245
|
+
lines = [
|
|
246
|
+
f"{fingerprint} {' '.join(info['codes'])} {' '.join(info['hosts'])}"
|
|
247
|
+
for fingerprint, info in found.items()
|
|
248
|
+
]
|
|
249
|
+
lines.extend(plain)
|
|
250
|
+
return lines
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _policy(args: argparse.Namespace) -> Policy:
|
|
254
|
+
stale = getattr(args, "stale_days", 90)
|
|
255
|
+
if stale < 1:
|
|
256
|
+
raise ValueError("--stale-days must be 1 or more")
|
|
257
|
+
return Policy(
|
|
258
|
+
flag_outside_push=not getattr(args, "no_outside", False),
|
|
259
|
+
flag_weak=not getattr(args, "no_weak", False),
|
|
260
|
+
stale_days=stale,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _waivers(args: argparse.Namespace) -> list[Waiver]:
|
|
265
|
+
path = getattr(args, "waivers", None)
|
|
266
|
+
return load_waivers(path) if path else []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _report_unused(waivers: list[Waiver], used: set[int]) -> None:
|
|
270
|
+
for waiver in waivers:
|
|
271
|
+
if waiver.index not in used:
|
|
272
|
+
print(f"waiver {waiver.label} ({waiver.code}) matched nothing; remove it if the risk is gone", file=sys.stderr)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _observe_one(args: argparse.Namespace) -> HostObservation:
|
|
276
|
+
if args.local:
|
|
277
|
+
return collect_local(args.account)
|
|
278
|
+
if args.host:
|
|
279
|
+
return collect_remote(args.host, args.account, accept_new=args.accept_new)
|
|
280
|
+
raw = read_observation(args.observation)
|
|
281
|
+
try:
|
|
282
|
+
if len(raw.encode("utf-8", errors="replace")) > MAX_INPUT_BYTES:
|
|
283
|
+
raise ValueError("observation is larger than 1 MB")
|
|
284
|
+
address = "stdin" if args.observation == "-" else "file"
|
|
285
|
+
return parse_protocol(raw, address=address)
|
|
286
|
+
finally:
|
|
287
|
+
del raw
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _targets(path: Path) -> list[InventoryHost]:
|
|
291
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
292
|
+
try:
|
|
293
|
+
if len(text.encode("utf-8", errors="replace")) > MAX_INPUT_BYTES:
|
|
294
|
+
raise ValueError("inventory is larger than 1 MB")
|
|
295
|
+
if "PRIVATE KEY" in text or "-----BEGIN" in text or "PuTTY-User-Key-File" in text:
|
|
296
|
+
raise PrivateKeyError("private key material was present and was not recorded")
|
|
297
|
+
targets = parse_inventory(text, source=path.name)
|
|
298
|
+
finally:
|
|
299
|
+
del text
|
|
300
|
+
if not targets:
|
|
301
|
+
raise ValueError("inventory has no hosts")
|
|
302
|
+
if len(targets) > MAX_INVENTORY_HOSTS:
|
|
303
|
+
raise ValueError(f"inventory has more than {MAX_INVENTORY_HOSTS} hosts")
|
|
304
|
+
return targets
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _observe_target(target: InventoryHost, args: argparse.Namespace) -> HostObservation:
|
|
308
|
+
observed = collect_remote(
|
|
309
|
+
target.destination,
|
|
310
|
+
args.account,
|
|
311
|
+
target.port,
|
|
312
|
+
accept_new=args.accept_new,
|
|
313
|
+
identity_file=target.identity_file,
|
|
314
|
+
)
|
|
315
|
+
observed.name = target.name
|
|
316
|
+
observed.address = target.destination
|
|
317
|
+
return observed
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _check_one(args: argparse.Namespace) -> int:
|
|
321
|
+
push = _load_allow(args.allow)
|
|
322
|
+
waivers = _waivers(args)
|
|
323
|
+
host = _observe_one(args)
|
|
324
|
+
if args.redact_comments:
|
|
325
|
+
push = _redact(host, push)
|
|
326
|
+
policy = _policy(args)
|
|
327
|
+
findings = apply_drift(evaluate(host, push, policy), _load_prior(args.prior, host.name))
|
|
328
|
+
used: set[int] = set()
|
|
329
|
+
findings = apply_waivers(findings, host.name, waivers, date.today(), used)
|
|
330
|
+
rows = _rows(host, push, policy, args)
|
|
331
|
+
document = _envelope(push)
|
|
332
|
+
document["host"] = _host_report(host, findings, rows)
|
|
333
|
+
encoded = json.dumps(document, indent=2) + "\n"
|
|
334
|
+
if args.output:
|
|
335
|
+
args.output.write_text(encoded, encoding="utf-8")
|
|
336
|
+
if args.csv:
|
|
337
|
+
_write_csv(args.csv, rows)
|
|
338
|
+
if args.evidence:
|
|
339
|
+
_write_evidence(args.evidence, document)
|
|
340
|
+
if args.json:
|
|
341
|
+
sys.stdout.write(encoded)
|
|
342
|
+
else:
|
|
343
|
+
_print_human(document, _paint(args), args.verbose)
|
|
344
|
+
if args.keys:
|
|
345
|
+
print()
|
|
346
|
+
_print_keys(rows)
|
|
347
|
+
_report_unused(waivers, used)
|
|
348
|
+
return 1 if host_failed(document) else 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _unreachable(name: str, address: str, detail: str, code: str = "unreachable") -> dict:
|
|
352
|
+
title = "Host could not be checked" if code == "unreachable" else "Collector output was refused"
|
|
353
|
+
return {
|
|
354
|
+
"name": name,
|
|
355
|
+
"address": address,
|
|
356
|
+
"role": "observed",
|
|
357
|
+
"observedAt": now_iso(),
|
|
358
|
+
"posture": "fail",
|
|
359
|
+
"sshd": {},
|
|
360
|
+
"findings": [
|
|
361
|
+
{
|
|
362
|
+
"code": code,
|
|
363
|
+
"severity": "fail",
|
|
364
|
+
"title": title,
|
|
365
|
+
"detail": detail[:180],
|
|
366
|
+
}
|
|
367
|
+
],
|
|
368
|
+
"keys": [],
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _check_inventory(args: argparse.Namespace) -> int:
|
|
373
|
+
if args.prior:
|
|
374
|
+
raise ValueError("--prior applies to one host; leave it off with --inventory")
|
|
375
|
+
push = _load_allow(args.allow)
|
|
376
|
+
waivers = _waivers(args)
|
|
377
|
+
if args.redact_comments:
|
|
378
|
+
push = [dataclasses.replace(key, comment="") for key in push]
|
|
379
|
+
targets = _targets(args.inventory)
|
|
380
|
+
policy = _policy(args)
|
|
381
|
+
host_reports = []
|
|
382
|
+
all_rows: list[dict] = []
|
|
383
|
+
found: dict[str, dict[str, list[str]]] = {}
|
|
384
|
+
plain: list[str] = []
|
|
385
|
+
used: set[int] = set()
|
|
386
|
+
today = date.today()
|
|
387
|
+
for target in targets:
|
|
388
|
+
try:
|
|
389
|
+
observed = _observe_target(target, args)
|
|
390
|
+
except ProtocolError as exc:
|
|
391
|
+
host_reports.append(_unreachable(target.name, target.destination, str(exc), code="collector-output"))
|
|
392
|
+
plain.append(f"{target.name} collector-output")
|
|
393
|
+
continue
|
|
394
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
395
|
+
host_reports.append(_unreachable(target.name, target.destination, str(exc)))
|
|
396
|
+
plain.append(f"{target.name} unreachable")
|
|
397
|
+
continue
|
|
398
|
+
if args.redact_comments:
|
|
399
|
+
_redact(observed, [])
|
|
400
|
+
findings = apply_waivers(evaluate(observed, push, policy), target.name, waivers, today, used)
|
|
401
|
+
rows = _rows(observed, push, policy, args)
|
|
402
|
+
all_rows.extend(rows)
|
|
403
|
+
host_reports.append(_host_report(observed, findings, rows))
|
|
404
|
+
_remember_failure(found, plain, target.name, host_reports[-1]["findings"])
|
|
405
|
+
posture = "fail" if any(item["posture"] == "fail" for item in host_reports) else "pass"
|
|
406
|
+
lines = _unique_failure_lines(found, plain)
|
|
407
|
+
document = _envelope(push)
|
|
408
|
+
document.update({"fingerprints": list(found), "posture": posture, "hosts": host_reports})
|
|
409
|
+
encoded = json.dumps(document, indent=2) + "\n"
|
|
410
|
+
if args.output:
|
|
411
|
+
args.output.write_text(encoded, encoding="utf-8")
|
|
412
|
+
if args.csv:
|
|
413
|
+
_write_csv(args.csv, all_rows)
|
|
414
|
+
if args.evidence:
|
|
415
|
+
_write_evidence(args.evidence, document)
|
|
416
|
+
if args.json:
|
|
417
|
+
sys.stdout.write(encoded)
|
|
418
|
+
else:
|
|
419
|
+
paint = _paint(args)
|
|
420
|
+
for line in lines:
|
|
421
|
+
first, _, rest = line.partition(" ")
|
|
422
|
+
print(f"{paint.fingerprints(first) if first.startswith('SHA256:') else paint('bold', first)} {paint('fail', rest)}")
|
|
423
|
+
failing = sum(1 for item in host_reports if item["posture"] == "fail")
|
|
424
|
+
warning = sum(1 for item in host_reports if item["posture"] == "warn")
|
|
425
|
+
summary = f"{len(targets)} hosts · {failing} failing · {warning} to review · {len(targets) - failing - warning} clear"
|
|
426
|
+
print(paint("dim" if lines else "pass", summary))
|
|
427
|
+
if args.keys:
|
|
428
|
+
print()
|
|
429
|
+
_print_keys(all_rows)
|
|
430
|
+
_report_unused(waivers, used)
|
|
431
|
+
return 1 if posture == "fail" else 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def host_failed(document: dict) -> bool:
|
|
435
|
+
return document["host"]["posture"] == "fail"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _wanted_fingerprints(args: argparse.Namespace) -> list[str]:
|
|
439
|
+
wanted: list[str] = []
|
|
440
|
+
for value in args.fingerprint:
|
|
441
|
+
if not FINGERPRINT.fullmatch(value):
|
|
442
|
+
raise ValueError(f"not a fingerprint: {value} (expected SHA256:..., as ssh-keygen -lf prints)")
|
|
443
|
+
wanted.append(value)
|
|
444
|
+
for path in args.key or []:
|
|
445
|
+
if path.stat().st_size > MAX_INPUT_BYTES:
|
|
446
|
+
raise ValueError(f"{path} is larger than 1 MB")
|
|
447
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
448
|
+
try:
|
|
449
|
+
keys, _issues = parse_public_keys(text)
|
|
450
|
+
finally:
|
|
451
|
+
del text
|
|
452
|
+
if not keys:
|
|
453
|
+
raise ValueError(f"{path} has no public key")
|
|
454
|
+
wanted.extend(key.fingerprint for key in keys)
|
|
455
|
+
if not wanted:
|
|
456
|
+
raise ValueError("give a fingerprint, or --key with a public key file")
|
|
457
|
+
return list(dict.fromkeys(wanted))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _matches(host: HostObservation, wanted: set[str]) -> list[dict]:
|
|
461
|
+
"""Every place on this host that accepts one of the wanted keys, plus logins seen in the log."""
|
|
462
|
+
hits: list[dict] = []
|
|
463
|
+
listed: set[tuple[str, str]] = set()
|
|
464
|
+
for account in host.accounts:
|
|
465
|
+
if account.read_state != "ok":
|
|
466
|
+
continue
|
|
467
|
+
reach = reach_of(account)
|
|
468
|
+
for key in account.keys:
|
|
469
|
+
if key.fingerprint not in wanted:
|
|
470
|
+
continue
|
|
471
|
+
login = host.logins.get((account.user, "CA:" + key.fingerprint if key.cert_authority else key.fingerprint))
|
|
472
|
+
listed.add((account.user, key.fingerprint))
|
|
473
|
+
hits.append(
|
|
474
|
+
{
|
|
475
|
+
"host": host.name,
|
|
476
|
+
"account": account.user,
|
|
477
|
+
"fingerprint": key.fingerprint,
|
|
478
|
+
"how": "trusted as a certificate authority" if key.cert_authority else "authorized_keys",
|
|
479
|
+
"reach": reach.level,
|
|
480
|
+
"reachVia": reach.via,
|
|
481
|
+
"from": list(key.options.source),
|
|
482
|
+
"lastUsed": _iso(login.last) if login else None,
|
|
483
|
+
"uses": login.count if login else 0,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
for (user, fingerprint), login in host.logins.items():
|
|
487
|
+
plain = fingerprint[3:] if fingerprint.startswith("CA:") else fingerprint
|
|
488
|
+
if plain in wanted and (user, plain) not in listed:
|
|
489
|
+
hits.append(
|
|
490
|
+
{
|
|
491
|
+
"host": host.name,
|
|
492
|
+
"account": user,
|
|
493
|
+
"fingerprint": plain,
|
|
494
|
+
"how": "logged in recently, but not in a readable authorized_keys",
|
|
495
|
+
"reach": "unknown",
|
|
496
|
+
"reachVia": "",
|
|
497
|
+
"from": [],
|
|
498
|
+
"lastUsed": _iso(login.last),
|
|
499
|
+
"uses": login.count,
|
|
500
|
+
}
|
|
501
|
+
)
|
|
502
|
+
return hits
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _iso(epoch: int | None) -> str | None:
|
|
506
|
+
from keywarden.access import _iso as to_iso
|
|
507
|
+
|
|
508
|
+
return to_iso(epoch)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _where(args: argparse.Namespace) -> int:
|
|
512
|
+
wanted = _wanted_fingerprints(args)
|
|
513
|
+
wanted_set = set(wanted)
|
|
514
|
+
hits: list[dict] = []
|
|
515
|
+
blind: list[str] = []
|
|
516
|
+
if args.inventory:
|
|
517
|
+
for target in _targets(args.inventory):
|
|
518
|
+
try:
|
|
519
|
+
host = _observe_target(target, args)
|
|
520
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
521
|
+
blind.append(f"{target.name} not checked: {str(exc)[:120]}")
|
|
522
|
+
continue
|
|
523
|
+
hits.extend(_matches(host, wanted_set))
|
|
524
|
+
blind.extend(f"{host.name} {account.user} could not be read" for account in host.accounts if account.read_state == "unreadable")
|
|
525
|
+
else:
|
|
526
|
+
host = _observe_one(args)
|
|
527
|
+
hits.extend(_matches(host, wanted_set))
|
|
528
|
+
blind.extend(f"{host.name} {account.user} could not be read" for account in host.accounts if account.read_state == "unreadable")
|
|
529
|
+
if args.json:
|
|
530
|
+
sys.stdout.write(json.dumps({"fingerprints": wanted, "found": hits, "notChecked": blind}, indent=2) + "\n")
|
|
531
|
+
else:
|
|
532
|
+
for hit in hits:
|
|
533
|
+
source = ",".join(hit["from"]) or "any address"
|
|
534
|
+
last = hit["lastUsed"] or "no login in the logs"
|
|
535
|
+
print(f"{hit['host']} {hit['account']} {hit['fingerprint']} {hit['how']} reach={hit['reach']} from={source} last={last}")
|
|
536
|
+
if not hits:
|
|
537
|
+
print("not found on any host that could be read")
|
|
538
|
+
for line in blind:
|
|
539
|
+
print(line, file=sys.stderr)
|
|
540
|
+
return 0 if hits else 1
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _add_source(parser: argparse.ArgumentParser, inventory_help: str) -> None:
|
|
544
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
545
|
+
source.add_argument("--local", action="store_true", help="read this machine")
|
|
546
|
+
source.add_argument("--host", help="ssh to user@host in batch mode and read it")
|
|
547
|
+
source.add_argument("--observation", help="collector output file, or - for stdin")
|
|
548
|
+
source.add_argument("--inventory", type=Path, help=inventory_help)
|
|
549
|
+
parser.add_argument("--account", action="append", default=[], help="extra account to read; repeatable")
|
|
550
|
+
parser.add_argument(
|
|
551
|
+
"--accept-new",
|
|
552
|
+
action="store_true",
|
|
553
|
+
help="trust a host key that is not in known_hosts; the default is to fail that host",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
558
|
+
parser = argparse.ArgumentParser(
|
|
559
|
+
prog="keywarden",
|
|
560
|
+
description="Check SSH posture around the public keys you push. Does not store them.",
|
|
561
|
+
)
|
|
562
|
+
parser.add_argument("--version", action="version", version=f"keywarden {__version__}")
|
|
563
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
564
|
+
|
|
565
|
+
check = sub.add_parser("check", help="check one host, or every host in an Ansible inventory")
|
|
566
|
+
_add_source(check, "Ansible INI inventory; failures only, one SSH per host")
|
|
567
|
+
check.add_argument("--allow", type=Path, help="public keys you push; fingerprinted, not copied into the report")
|
|
568
|
+
check.add_argument("--prior", type=Path, help="earlier keywarden JSON report, for drift")
|
|
569
|
+
check.add_argument("--output", type=Path, help="write the JSON report here")
|
|
570
|
+
check.add_argument("--json", action="store_true", help="print the JSON report")
|
|
571
|
+
check.add_argument("--csv", type=Path, help="write the access review table (one row per authorized key) here")
|
|
572
|
+
check.add_argument("--keys", action="store_true", help="also print the access review table")
|
|
573
|
+
check.add_argument("--evidence", type=Path, help="write an evidence report (HTML) with control mapping for an assessor")
|
|
574
|
+
check.add_argument("--verbose", "-v", action="store_true", help="also print passing checks")
|
|
575
|
+
check.add_argument("--color", choices=["auto", "always", "never"], default="auto", help="colour the terminal output (default: auto; NO_COLOR is honoured)")
|
|
576
|
+
check.add_argument("--waivers", type=Path, help="accepted risks, JSON or TOML; each needs a reason, an owner or ticket, and an end date")
|
|
577
|
+
check.add_argument("--stale-days", type=int, default=90, help="a key with no login in this many days is stale (default 90)")
|
|
578
|
+
check.add_argument("--no-outside", action="store_true", help="do not flag keys missing from --allow")
|
|
579
|
+
check.add_argument("--no-weak", action="store_true", help="do not flag DSA or RSA under 3072 bits")
|
|
580
|
+
check.add_argument(
|
|
581
|
+
"--redact-comments",
|
|
582
|
+
action="store_true",
|
|
583
|
+
help="leave key comments (often an email or machine name) out of the output",
|
|
584
|
+
)
|
|
585
|
+
check.add_argument("--redact-addresses", action="store_true", help="leave the last login address out of the output")
|
|
586
|
+
|
|
587
|
+
where = sub.add_parser("where", help="find every host and account that accepts a key")
|
|
588
|
+
where.add_argument("fingerprint", nargs="*", default=[], help="SHA256:... fingerprint; repeatable")
|
|
589
|
+
where.add_argument("--key", type=Path, action="append", help="public key file to look for; repeatable")
|
|
590
|
+
where.add_argument("--json", action="store_true", help="print JSON")
|
|
591
|
+
_add_source(where, "Ansible INI inventory; one SSH per host")
|
|
592
|
+
|
|
593
|
+
ui = sub.add_parser("ui", help="open the checks in a browser tab served from this machine only")
|
|
594
|
+
ui.add_argument("--port", type=int, default=0, help="port on 127.0.0.1 (default: any free port)")
|
|
595
|
+
ui.add_argument("--no-browser", action="store_true", help="print the link instead of opening it")
|
|
596
|
+
return parser
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def main(argv: list[str] | None = None) -> int:
|
|
600
|
+
parser = build_parser()
|
|
601
|
+
args = parser.parse_args(argv)
|
|
602
|
+
try:
|
|
603
|
+
if args.command == "ui":
|
|
604
|
+
from keywarden.ui import serve
|
|
605
|
+
|
|
606
|
+
return serve(args.port, open_browser=not args.no_browser)
|
|
607
|
+
if args.command == "where":
|
|
608
|
+
return _where(args)
|
|
609
|
+
if args.inventory:
|
|
610
|
+
return _check_inventory(args)
|
|
611
|
+
return _check_one(args)
|
|
612
|
+
except PrivateKeyError:
|
|
613
|
+
print(
|
|
614
|
+
"Refusing to continue: a private key was in the input. Keywarden accepts public keys only and did not keep a copy.",
|
|
615
|
+
file=sys.stderr,
|
|
616
|
+
)
|
|
617
|
+
return 2
|
|
618
|
+
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
|
619
|
+
print(str(exc), file=sys.stderr)
|
|
620
|
+
return 2
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
if __name__ == "__main__":
|
|
624
|
+
sys.exit(main())
|