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/controls.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Which framework controls each check gives evidence for.
|
|
2
|
+
|
|
3
|
+
This is a mapping to help an assessor, not an attestation. Keywarden does not
|
|
4
|
+
claim MFA coverage: turning passwords off is not the same as requiring a second factor. A passing check is
|
|
5
|
+
evidence toward a control on the hosts that were checked; it does not show the
|
|
6
|
+
control is met. Requirement text is paraphrased; read the framework itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
FRAMEWORKS = {
|
|
12
|
+
"cis": "CIS Controls v8",
|
|
13
|
+
"nist": "NIST SP 800-53 Rev. 5",
|
|
14
|
+
"pci": "PCI DSS v4.0",
|
|
15
|
+
"cip": "NERC CIP",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Control names, paraphrased so the report reads without the source documents open.
|
|
19
|
+
NAMES = {
|
|
20
|
+
# CIS Controls v8 safeguards
|
|
21
|
+
"cis:3.3": "Configure data access control lists",
|
|
22
|
+
"cis:3.10": "Encrypt sensitive data in transit",
|
|
23
|
+
"cis:4.1": "Establish and maintain a secure configuration process",
|
|
24
|
+
"cis:4.7": "Manage default accounts on enterprise assets and software",
|
|
25
|
+
"cis:5.1": "Establish and maintain an inventory of accounts",
|
|
26
|
+
"cis:5.2": "Use unique passwords",
|
|
27
|
+
"cis:5.3": "Disable dormant accounts",
|
|
28
|
+
"cis:5.4": "Restrict administrator privileges to dedicated administrator accounts",
|
|
29
|
+
"cis:6.1": "Establish an access granting process",
|
|
30
|
+
"cis:6.2": "Establish an access revoking process",
|
|
31
|
+
"cis:6.8": "Define and maintain role-based access control",
|
|
32
|
+
"cis:8.2": "Collect audit logs",
|
|
33
|
+
"cis:8.11": "Conduct audit log reviews",
|
|
34
|
+
# NIST SP 800-53 Rev. 5
|
|
35
|
+
"nist:AC-2": "Account Management",
|
|
36
|
+
"nist:AC-2(3)": "Account Management | Disable Accounts",
|
|
37
|
+
"nist:AC-3": "Access Enforcement",
|
|
38
|
+
"nist:AC-6": "Least Privilege",
|
|
39
|
+
"nist:AC-6(5)": "Least Privilege | Privileged Accounts",
|
|
40
|
+
"nist:AC-17": "Remote Access",
|
|
41
|
+
"nist:AU-2": "Event Logging",
|
|
42
|
+
"nist:AU-6": "Audit Record Review, Analysis, and Reporting",
|
|
43
|
+
"nist:AU-12": "Audit Record Generation",
|
|
44
|
+
"nist:CM-6": "Configuration Settings",
|
|
45
|
+
"nist:CM-7": "Least Functionality",
|
|
46
|
+
"nist:IA-2": "Identification and Authentication (Organizational Users)",
|
|
47
|
+
"nist:IA-2(5)": "Identification and Authentication | Individual Authentication with Group Authentication",
|
|
48
|
+
"nist:IA-5": "Authenticator Management",
|
|
49
|
+
"nist:IA-5(1)": "Authenticator Management | Password-based Authentication",
|
|
50
|
+
"nist:IA-5(2)": "Authenticator Management | Public Key-based Authentication",
|
|
51
|
+
"nist:SC-13": "Cryptographic Protection",
|
|
52
|
+
"nist:SC-17": "Public Key Infrastructure Certificates",
|
|
53
|
+
# PCI DSS v4.0
|
|
54
|
+
"pci:2.2.1": "Configuration standards are developed, implemented, and maintained",
|
|
55
|
+
"pci:2.2.4": "Only necessary services, protocols, and functions are enabled",
|
|
56
|
+
"pci:2.2.7": "All non-console administrative access is encrypted using strong cryptography",
|
|
57
|
+
"pci:7.2.1": "Access control model covers appropriate access by role",
|
|
58
|
+
"pci:7.2.2": "Access is assigned based on job function and least privilege",
|
|
59
|
+
"pci:7.2.4": "User accounts and access privileges are reviewed at least every six months",
|
|
60
|
+
"pci:8.2.2": "Group, shared, or generic accounts are used only when necessary and are managed",
|
|
61
|
+
"pci:8.2.5": "Access for terminated users is immediately revoked",
|
|
62
|
+
"pci:8.2.6": "Inactive user accounts are removed or disabled within 90 days",
|
|
63
|
+
"pci:8.3.1": "User access is authenticated with at least one authentication factor",
|
|
64
|
+
"pci:8.3.6": "Passwords meet minimum complexity",
|
|
65
|
+
"pci:10.2.1": "Audit logs are enabled and active for all system components",
|
|
66
|
+
"pci:10.4.1": "Audit logs are reviewed at least once daily",
|
|
67
|
+
# NERC CIP
|
|
68
|
+
"cip:CIP-004-7 R4.1": "Authorize electronic access",
|
|
69
|
+
"cip:CIP-004-7 R4.2": "Verify quarterly that individuals with active electronic access have authorization records",
|
|
70
|
+
"cip:CIP-004-7 R4.3": "Verify every 15 calendar months that user accounts and privileges are correct and necessary",
|
|
71
|
+
"cip:CIP-004-7 R5.1": "Remove the ability for unescorted access and Interactive Remote Access on termination",
|
|
72
|
+
"cip:CIP-005-7 R2.1": "Use an Intermediate System for Interactive Remote Access",
|
|
73
|
+
"cip:CIP-005-7 R2.2": "Use encryption that terminates at the Intermediate System",
|
|
74
|
+
"cip:CIP-007-6 R4.1": "Log events, including successful login attempts",
|
|
75
|
+
"cip:CIP-007-6 R5.1": "Have a method to enforce authentication of interactive user access",
|
|
76
|
+
"cip:CIP-007-6 R5.2": "Identify and inventory default or generic accounts",
|
|
77
|
+
"cip:CIP-007-6 R5.3": "Identify individuals who have authorized access to shared accounts",
|
|
78
|
+
"cip:CIP-007-6 R5.5": "Password-only authentication: length and complexity",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# check code -> control keys
|
|
82
|
+
MAPPING: dict[str, list[str]] = {
|
|
83
|
+
"permit-root": ["cis:5.4", "nist:AC-6(5)", "nist:IA-2(5)", "pci:8.2.2", "cip:CIP-007-6 R5.2", "cip:CIP-007-6 R5.3"],
|
|
84
|
+
"root-authorized-key": ["cis:5.4", "nist:AC-6(5)", "nist:IA-2(5)", "pci:8.2.2", "cip:CIP-007-6 R5.3"],
|
|
85
|
+
"root-account": ["cis:4.7", "cis:5.2", "nist:AC-2", "nist:AC-6(5)", "nist:IA-5(1)", "pci:8.2.2", "cip:CIP-007-6 R5.2"],
|
|
86
|
+
"password-auth": ["cis:4.1", "nist:IA-2", "nist:IA-5(1)", "pci:8.3.1", "cip:CIP-007-6 R5.1", "cip:CIP-007-6 R5.5"],
|
|
87
|
+
"keyboard-interactive": ["cis:4.1", "nist:IA-2", "nist:IA-5(1)", "pci:8.3.1", "cip:CIP-007-6 R5.1"],
|
|
88
|
+
"empty-passwords": ["cis:5.2", "nist:IA-5(1)", "pci:8.3.1", "pci:8.3.6", "cip:CIP-007-6 R5.5"],
|
|
89
|
+
"auth-methods": ["cis:4.1", "nist:IA-2", "nist:IA-5(1)", "pci:8.3.1", "cip:CIP-007-6 R5.1"],
|
|
90
|
+
"pubkey-auth": ["nist:IA-5(2)", "pci:8.3.1", "cip:CIP-007-6 R5.1"],
|
|
91
|
+
"permissions": ["cis:3.3", "cis:4.1", "nist:AC-3", "nist:CM-6", "pci:2.2.1"],
|
|
92
|
+
"weak-algorithm": ["cis:3.10", "nist:IA-5(2)", "nist:SC-13", "pci:2.2.7", "cip:CIP-005-7 R2.2"],
|
|
93
|
+
"accepted-algorithms": ["cis:3.10", "nist:SC-13", "pci:2.2.7", "cip:CIP-005-7 R2.2"],
|
|
94
|
+
"ca-algorithms": ["cis:3.10", "nist:SC-13", "nist:SC-17", "pci:2.2.7"],
|
|
95
|
+
"outside-push": ["cis:5.1", "cis:6.1", "cis:6.2", "nist:AC-2", "pci:7.2.1", "pci:7.2.4", "pci:8.2.5", "cip:CIP-004-7 R4.1", "cip:CIP-004-7 R4.3"],
|
|
96
|
+
"cert-authority": ["nist:IA-5(2)", "nist:SC-17", "pci:7.2.2", "cip:CIP-004-7 R4.1"],
|
|
97
|
+
"ssh-certificate": ["nist:IA-5(2)", "nist:SC-17"],
|
|
98
|
+
"trusted-user-ca": ["nist:IA-5(2)", "nist:SC-17", "cip:CIP-004-7 R4.1"],
|
|
99
|
+
"revoked-keys": ["nist:IA-5(2)", "nist:SC-17", "pci:8.2.5", "cip:CIP-004-7 R5.1"],
|
|
100
|
+
"authorized-keys-command": ["cis:4.1", "nist:AC-2", "nist:CM-6", "pci:2.2.1"],
|
|
101
|
+
"authorized-keys-file": ["cis:4.1", "nist:CM-6", "pci:2.2.1"],
|
|
102
|
+
"hostbased-auth": ["cis:4.1", "nist:IA-2", "nist:AC-17", "pci:8.3.1", "cip:CIP-007-6 R5.1"],
|
|
103
|
+
"sshd-config": ["cis:4.1", "nist:CM-6", "pci:2.2.1"],
|
|
104
|
+
"match-blocks": ["cis:4.1", "nist:CM-6", "pci:2.2.1"],
|
|
105
|
+
"key-to-root": ["cis:5.4", "cis:6.8", "nist:AC-6", "nist:AC-6(5)", "pci:7.2.2", "cip:CIP-004-7 R4.3"],
|
|
106
|
+
"key-source": ["nist:AC-17", "nist:AC-6", "pci:7.2.2", "cip:CIP-005-7 R2.1"],
|
|
107
|
+
"forced-command-forwarding": ["cis:4.1", "nist:CM-7", "nist:AC-17", "pci:2.2.4"],
|
|
108
|
+
"key-environment": ["cis:4.1", "nist:CM-6", "nist:CM-7", "pci:2.2.1"],
|
|
109
|
+
"expired-key": ["nist:IA-5", "pci:8.2.6"],
|
|
110
|
+
"log-level": ["cis:8.2", "nist:AU-2", "nist:AU-12", "pci:10.2.1", "cip:CIP-007-6 R4.1"],
|
|
111
|
+
"auth-log": ["cis:8.11", "nist:AU-6", "pci:10.4.1"],
|
|
112
|
+
"stale-key": ["cis:5.3", "cis:6.2", "nist:AC-2", "nist:AC-2(3)", "nist:IA-5", "pci:7.2.4", "pci:8.2.6", "cip:CIP-004-7 R4.2", "cip:CIP-004-7 R4.3"],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def controls_for(code: str) -> list[dict]:
|
|
117
|
+
result = []
|
|
118
|
+
for key in MAPPING.get(code, []):
|
|
119
|
+
framework, control = key.split(":", 1)
|
|
120
|
+
result.append({"framework": framework, "id": control, "name": NAMES[key]})
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def markdown_table() -> str:
|
|
125
|
+
"""The table in docs/compliance.md, generated so the docs never drift from the code."""
|
|
126
|
+
lines = ["| Check | " + " | ".join(FRAMEWORKS.values()) + " |", "| --- |" + " --- |" * len(FRAMEWORKS)]
|
|
127
|
+
for code in sorted(MAPPING):
|
|
128
|
+
cells = []
|
|
129
|
+
for framework in FRAMEWORKS:
|
|
130
|
+
ids = [key.split(":", 1)[1] for key in MAPPING[code] if key.startswith(framework + ":")]
|
|
131
|
+
cells.append(", ".join(ids) or "–")
|
|
132
|
+
lines.append(f"| `{code}` | " + " | ".join(cells) + " |")
|
|
133
|
+
return "\n".join(lines)
|
keywarden/evidence.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""An evidence report: one self-contained HTML file to hand an assessor.
|
|
2
|
+
|
|
3
|
+
It records scope, who ran the check and with what visibility, each finding
|
|
4
|
+
with the framework controls it bears on, the access review table, accepted
|
|
5
|
+
risks, and a SHA-256 of the JSON report it was built from. No key bodies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
from html import escape
|
|
15
|
+
|
|
16
|
+
from keywarden.controls import FRAMEWORKS, MAPPING, NAMES
|
|
17
|
+
|
|
18
|
+
_RANK = {"fail": 3, "warn": 2, "accepted": 1, "pass": 0}
|
|
19
|
+
_STATUS = {3: ("fail", "Checks failed"), 2: ("warn", "Checks to review"), 1: ("accepted", "Accepted risk"), 0: ("pass", "Checks passed")}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hosts(document: dict) -> list[dict]:
|
|
23
|
+
return document.get("hosts") or ([document["host"]] if document.get("host") else [])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _operator() -> str:
|
|
27
|
+
try:
|
|
28
|
+
return f"{getpass.getuser()}@{socket.gethostname()}"
|
|
29
|
+
except Exception:
|
|
30
|
+
return "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def control_matrix(document: dict) -> dict[str, list[dict]]:
|
|
34
|
+
"""framework -> rows of {id, name, status, codes, hosts}."""
|
|
35
|
+
seen: dict[str, dict] = {}
|
|
36
|
+
for host in _hosts(document):
|
|
37
|
+
for item in host.get("findings", []):
|
|
38
|
+
for key in MAPPING.get(item.get("code", ""), []):
|
|
39
|
+
entry = seen.setdefault(key, {"rank": -1, "codes": set(), "hosts": set()})
|
|
40
|
+
rank = _RANK.get(item.get("severity"), 0)
|
|
41
|
+
entry["codes"].add(item["code"])
|
|
42
|
+
if rank > 0:
|
|
43
|
+
entry["hosts"].add(host.get("name", "?"))
|
|
44
|
+
entry["rank"] = max(entry["rank"], rank)
|
|
45
|
+
matrix: dict[str, list[dict]] = {}
|
|
46
|
+
for framework in FRAMEWORKS:
|
|
47
|
+
rows = []
|
|
48
|
+
for key in sorted({k for codes in MAPPING.values() for k in codes if k.startswith(framework + ":")}, key=_sort_key):
|
|
49
|
+
entry = seen.get(key)
|
|
50
|
+
if entry is None:
|
|
51
|
+
status, label = "none", "Not assessed in this run"
|
|
52
|
+
codes, hosts = [], []
|
|
53
|
+
else:
|
|
54
|
+
status, label = _STATUS[entry["rank"]]
|
|
55
|
+
codes, hosts = sorted(entry["codes"]), sorted(entry["hosts"])
|
|
56
|
+
rows.append({"id": key.split(":", 1)[1], "name": NAMES[key], "status": status, "label": label, "codes": codes, "hosts": hosts})
|
|
57
|
+
matrix[framework] = rows
|
|
58
|
+
return matrix
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sort_key(key: str):
|
|
62
|
+
control = key.split(":", 1)[1]
|
|
63
|
+
parts = []
|
|
64
|
+
for token in control.replace("(", ".").replace(")", "").replace("-", ".").replace(" ", ".").split("."):
|
|
65
|
+
parts.append((0, int(token), "") if token.isdigit() else (1, 0, token))
|
|
66
|
+
return parts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_evidence(document: dict) -> str:
|
|
70
|
+
canonical = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
71
|
+
digest = hashlib.sha256(canonical).hexdigest()
|
|
72
|
+
hosts = _hosts(document)
|
|
73
|
+
keys = [row for host in hosts for row in host.get("keys", [])]
|
|
74
|
+
counts = {name: sum(1 for host in hosts if host.get("posture") == name) for name in ("fail", "warn", "pass")}
|
|
75
|
+
failed_checks = sum(1 for host in hosts for item in host.get("findings", []) if item.get("severity") == "fail")
|
|
76
|
+
waivers = [(host.get("name"), item) for host in hosts for item in host.get("findings", []) if item.get("severity") == "accepted"]
|
|
77
|
+
matrix = control_matrix(document)
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
add = out.append
|
|
80
|
+
add("<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>")
|
|
81
|
+
add("<title>Keywarden evidence report</title><style>")
|
|
82
|
+
add(_CSS)
|
|
83
|
+
add("</style></head><body><main>")
|
|
84
|
+
add("<header><p class='eyebrow'>Keywarden evidence report</p><h1>SSH access and configuration</h1>")
|
|
85
|
+
add(f"<p class='lede'>{len(hosts)} host{'s' if len(hosts) != 1 else ''} checked. Generated {escape(document.get('generatedAt', ''))} by Keywarden {escape(str(document.get('version', '')))}.</p></header>")
|
|
86
|
+
add("<section><h2>Run</h2><dl class='facts'>")
|
|
87
|
+
for label, value in (
|
|
88
|
+
("Run by", _operator()),
|
|
89
|
+
("Generated", document.get("generatedAt", "")),
|
|
90
|
+
("Keywarden version", document.get("version", "")),
|
|
91
|
+
("Keys compared against", f"{len(document.get('push', []))} pushed public key fingerprint(s)"),
|
|
92
|
+
("Key material", "Omitted. Fingerprints identify keys; no key bodies are in this report."),
|
|
93
|
+
("Report SHA-256", digest),
|
|
94
|
+
):
|
|
95
|
+
add(f"<dt>{escape(label)}</dt><dd>{escape(str(value))}</dd>")
|
|
96
|
+
add("</dl><p class='note'>The SHA-256 is computed over the JSON report embedded at the end of this file (keys sorted, no whitespace). Recompute it to show the findings were not edited.</p></section>")
|
|
97
|
+
|
|
98
|
+
add("<section><h2>Summary</h2><div class='tiles'>")
|
|
99
|
+
for label, value, cls in (
|
|
100
|
+
("Hosts", len(hosts), ""),
|
|
101
|
+
("Failing hosts", counts["fail"], "fail"),
|
|
102
|
+
("Hosts to review", counts["warn"], "warn"),
|
|
103
|
+
("Failed checks", failed_checks, "fail"),
|
|
104
|
+
("Keys reaching root", sum(1 for row in keys if row.get("reach") in ("root", "root-nopasswd")), "warn"),
|
|
105
|
+
("Stale keys", sum(1 for row in keys if row.get("usage") == "stale"), "warn"),
|
|
106
|
+
):
|
|
107
|
+
add(f"<div class='tile {cls}'><span>{escape(label)}</span><b>{value}</b></div>")
|
|
108
|
+
add("</div></section>")
|
|
109
|
+
|
|
110
|
+
add("<section><h2>Scope</h2><table><thead><tr><th>Host</th><th>Address</th><th>Result</th><th>Read as</th><th>sshd -T</th><th>Login log</th><th>Observed</th></tr></thead><tbody>")
|
|
111
|
+
for host in hosts:
|
|
112
|
+
posture = host.get("posture", "")
|
|
113
|
+
add(
|
|
114
|
+
f"<tr><td>{escape(host.get('name', ''))}</td><td class='mono'>{escape(str(host.get('address', '')))}</td>"
|
|
115
|
+
f"<td><span class='pill {escape(posture)}'>{escape(posture)}</span></td><td>{escape(str(host.get('privilege', '–')))}</td>"
|
|
116
|
+
f"<td>{escape(str(host.get('sshdRead', '–')))}</td><td>{escape(str(host.get('authLog', '–')))}</td><td class='mono'>{escape(str(host.get('observedAt', '')))}</td></tr>"
|
|
117
|
+
)
|
|
118
|
+
add("</tbody></table><p class='note'>Read as: root, sudo, or none. Checks that could not see something are reported as warnings, never as passes.</p></section>")
|
|
119
|
+
|
|
120
|
+
add("<section><h2>Control coverage</h2><p class='note'>Each control lists the Keywarden checks that bear on it for the hosts above. A passing check is evidence toward a control, not proof that the control is met; controls also cover process and people that a host scan cannot see. Requirement names are paraphrased.</p>")
|
|
121
|
+
for framework, title in FRAMEWORKS.items():
|
|
122
|
+
add(f"<h3>{escape(title)}</h3><table><thead><tr><th>Control</th><th>Requirement</th><th>Status</th><th>Checks</th><th>Hosts with issues</th></tr></thead><tbody>")
|
|
123
|
+
for row in matrix[framework]:
|
|
124
|
+
add(
|
|
125
|
+
f"<tr><td class='mono'>{escape(row['id'])}</td><td>{escape(row['name'])}</td><td><span class='pill {row['status']}'>{escape(row['label'])}</span></td>"
|
|
126
|
+
f"<td class='mono'>{escape(', '.join(row['codes']) or '–')}</td><td>{escape(', '.join(row['hosts']) or '–')}</td></tr>"
|
|
127
|
+
)
|
|
128
|
+
add("</tbody></table>")
|
|
129
|
+
add("</section>")
|
|
130
|
+
|
|
131
|
+
add("<section><h2>Findings</h2>")
|
|
132
|
+
order = {"fail": 0, "warn": 1, "accepted": 2, "pass": 3}
|
|
133
|
+
for host in hosts:
|
|
134
|
+
items = sorted(host.get("findings", []), key=lambda item: order.get(item.get("severity"), 4))
|
|
135
|
+
add(f"<h3>{escape(host.get('name', ''))} <span class='pill {escape(host.get('posture', ''))}'>{escape(host.get('posture', ''))}</span></h3>")
|
|
136
|
+
add("<table class='findings'><thead><tr><th>Result</th><th>Check</th><th>Detail</th><th>Controls</th></tr></thead><tbody>")
|
|
137
|
+
for item in items:
|
|
138
|
+
controls = ", ".join(f"{FRAMEWORKS[c['framework']].split()[0]} {c['id']}" for c in item.get("controls", []))
|
|
139
|
+
badge = f" <span class='pill warn'>{escape(item['drift'])}</span>" if item.get("drift") else ""
|
|
140
|
+
add(
|
|
141
|
+
f"<tr><td><span class='pill {escape(item.get('severity', ''))}'>{escape(item.get('severity', ''))}</span></td>"
|
|
142
|
+
f"<td><b>{escape(item.get('title', ''))}</b>{badge}<br><span class='mono dim'>{escape(item.get('code', ''))}</span></td>"
|
|
143
|
+
f"<td class='detail'>{escape(item.get('detail', ''))}</td><td class='small'>{escape(controls or '–')}</td></tr>"
|
|
144
|
+
)
|
|
145
|
+
add("</tbody></table>")
|
|
146
|
+
add("</section>")
|
|
147
|
+
|
|
148
|
+
add("<section><h2>Access review</h2>")
|
|
149
|
+
if keys:
|
|
150
|
+
add("<table><thead><tr><th>Host</th><th>Account</th><th>Fingerprint</th><th>Reach</th><th>Usage</th><th>Last used</th><th>From</th><th>In push</th><th>Comment</th></tr></thead><tbody>")
|
|
151
|
+
for row in keys:
|
|
152
|
+
in_push = "–" if row.get("inAllow") is None else ("yes" if row["inAllow"] else "no")
|
|
153
|
+
add(
|
|
154
|
+
f"<tr><td>{escape(row['host'])}</td><td>{escape(row['account'])}</td><td class='mono'>{escape(row['fingerprint'])}</td>"
|
|
155
|
+
f"<td>{escape(row['reach'])}</td><td>{escape(row['usage'])}</td><td class='mono'>{escape((row.get('lastUsed') or '–')[:10])}</td>"
|
|
156
|
+
f"<td class='mono'>{escape(', '.join(row['options'].get('from', [])) or 'any')}</td><td>{in_push}</td><td>{escape(row.get('comment') or '–')}</td></tr>"
|
|
157
|
+
)
|
|
158
|
+
add("</tbody></table>")
|
|
159
|
+
else:
|
|
160
|
+
add("<p>No authorized keys were readable.</p>")
|
|
161
|
+
add("</section>")
|
|
162
|
+
|
|
163
|
+
add("<section><h2>Accepted risks</h2>")
|
|
164
|
+
if waivers:
|
|
165
|
+
add("<table><thead><tr><th>Host</th><th>Check</th><th>Was</th><th>Owner / ticket</th><th>Until</th><th>Reason</th></tr></thead><tbody>")
|
|
166
|
+
for name, item in waivers:
|
|
167
|
+
for waiver in item.get("waivers", []):
|
|
168
|
+
owner = " / ".join(filter(None, [waiver.get("owner"), waiver.get("ticket")]))
|
|
169
|
+
add(
|
|
170
|
+
f"<tr><td>{escape(str(name))}</td><td class='mono'>{escape(item['code'])}</td><td>{escape(str(item.get('was', '')))}</td>"
|
|
171
|
+
f"<td>{escape(owner)}</td><td class='mono'>{escape(waiver.get('expires', ''))}</td><td>{escape(waiver.get('reason', ''))}</td></tr>"
|
|
172
|
+
)
|
|
173
|
+
add("</tbody></table>")
|
|
174
|
+
else:
|
|
175
|
+
add("<p>No findings were accepted under a waiver in this run.</p>")
|
|
176
|
+
add("</section>")
|
|
177
|
+
|
|
178
|
+
payload = json.dumps(document, indent=1).replace("</", "<\\/")
|
|
179
|
+
add(f"<section><h2>Report data</h2><p class='note'>The JSON report this page was built from. Its SHA-256 is above.</p><script type='application/json' id='keywarden-report'>{payload}</script></section>")
|
|
180
|
+
add("<footer>Generated by Keywarden, an open-source SSH access review tool. This report maps findings to controls to help an assessment; it is not a certification.</footer>")
|
|
181
|
+
add("</main></body></html>")
|
|
182
|
+
return "".join(out)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_CSS = """
|
|
186
|
+
:root { --ink:#111827; --muted:#4b5563; --line:#e5e7eb; --bg:#f8fafc; --card:#fff; --fail:#c4001a; --warn:#b54700; --pass:#1f7a35; --acpt:#3634a3; --none:#6b7280;
|
|
187
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: var(--ink); background: var(--bg); }
|
|
188
|
+
body { margin: 0; } main { max-width: 1100px; margin: 0 auto; padding: 32px 20px 48px; }
|
|
189
|
+
.eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: 12px; color: #0a6cff; font-weight: 600; margin: 0; }
|
|
190
|
+
h1 { font-size: 28px; margin: 4px 0 6px; letter-spacing: -.02em; } h2 { font-size: 19px; margin: 32px 0 10px; } h3 { font-size: 15px; margin: 22px 0 8px; }
|
|
191
|
+
.lede { color: var(--muted); margin: 0; } .note { color: var(--muted); font-size: 12.5px; max-width: 80ch; }
|
|
192
|
+
section { break-inside: auto; }
|
|
193
|
+
.facts { display: grid; grid-template-columns: 200px 1fr; gap: 6px 16px; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; margin: 0; }
|
|
194
|
+
.facts dt { color: var(--muted); } .facts dd { margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; }
|
|
195
|
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
|
|
196
|
+
.tile { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 14px; } .tile span { color: var(--muted); font-size: 12px; display: block; }
|
|
197
|
+
.tile b { font-size: 24px; font-variant-numeric: tabular-nums; } .tile.fail b { color: var(--fail); } .tile.warn b { color: var(--warn); }
|
|
198
|
+
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; font-size: 12.5px; }
|
|
199
|
+
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
200
|
+
th { background: #f1f5f9; font-weight: 600; color: var(--muted); font-size: 12px; }
|
|
201
|
+
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } .dim { color: var(--muted); } .small { font-size: 11.5px; color: var(--muted); }
|
|
202
|
+
.detail { overflow-wrap: anywhere; max-width: 520px; }
|
|
203
|
+
.pill { display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; white-space: nowrap; }
|
|
204
|
+
.pill.fail { color: var(--fail); } .pill.warn { color: var(--warn); } .pill.pass { color: var(--pass); } .pill.accepted { color: var(--acpt); } .pill.none { color: var(--none); }
|
|
205
|
+
footer { margin-top: 36px; color: var(--muted); font-size: 12px; }
|
|
206
|
+
@media print { :root { background: #fff; } main { padding: 0; } table, .facts, .tile { border-color: #cbd5e1; } h2 { break-after: avoid; } tr { break-inside: avoid; } }
|
|
207
|
+
"""
|
keywarden/inventory.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Ansible INI inventory, enough to know where to SSH. Passwords in the file are ignored."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
_RANGE = re.compile(r"\[(\d+):(\d+)\]")
|
|
9
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$")
|
|
10
|
+
_GROUP = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
|
|
11
|
+
_USER = re.compile(r"^[a-z_][-a-z0-9_.]*\$?$", re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
# Only connection fields are kept. Passwords, become settings, and everything
|
|
14
|
+
# else in the inventory are dropped as the line is read.
|
|
15
|
+
_ALIASES = {
|
|
16
|
+
"ansible_host": "host",
|
|
17
|
+
"ansible_ssh_host": "host",
|
|
18
|
+
"ansible_user": "user",
|
|
19
|
+
"ansible_ssh_user": "user",
|
|
20
|
+
"ansible_port": "port",
|
|
21
|
+
"ansible_ssh_port": "port",
|
|
22
|
+
"ansible_ssh_private_key_file": "key",
|
|
23
|
+
"ansible_private_key_file": "key",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class InventoryHost:
|
|
29
|
+
name: str
|
|
30
|
+
address: str
|
|
31
|
+
user: str | None
|
|
32
|
+
port: int | None
|
|
33
|
+
identity_file: str | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def destination(self) -> str:
|
|
37
|
+
if self.user:
|
|
38
|
+
return f"{self.user}@{self.address}"
|
|
39
|
+
return self.address
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _expand(token: str) -> list[str]:
|
|
43
|
+
match = _RANGE.search(token)
|
|
44
|
+
if not match:
|
|
45
|
+
return [token]
|
|
46
|
+
start, end = match.group(1), match.group(2)
|
|
47
|
+
first, last = int(start), int(end)
|
|
48
|
+
if last < first or last - first > 256:
|
|
49
|
+
raise ValueError(f"inventory range is too large: {token}")
|
|
50
|
+
width = len(start)
|
|
51
|
+
prefix, suffix = token[: match.start()], token[match.end() :]
|
|
52
|
+
return [f"{prefix}{index:0{width}d}{suffix}" for index in range(first, last + 1)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _port(value: str, name: str) -> int:
|
|
56
|
+
if not value.isdigit() or not 1 <= int(value) <= 65535:
|
|
57
|
+
raise ValueError(f"{name} has a port that is not 1-65535")
|
|
58
|
+
return int(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _unquote(value: str) -> str:
|
|
62
|
+
value = value.strip()
|
|
63
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
64
|
+
return value[1:-1]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _keep(key: str, value: str, into: dict[str, str]) -> None:
|
|
69
|
+
field = _ALIASES.get(key.strip())
|
|
70
|
+
if field:
|
|
71
|
+
into[field] = _unquote(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _looks_like_yaml(text: str, source: str | None) -> bool:
|
|
75
|
+
if source and source.lower().endswith((".yml", ".yaml", ".json")):
|
|
76
|
+
return True
|
|
77
|
+
for raw in text.splitlines():
|
|
78
|
+
line = raw.split("#", 1)[0].rstrip()
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
stripped = line.strip()
|
|
82
|
+
return stripped.startswith("{") or stripped == "---" or (stripped.endswith(":") and "=" not in stripped)
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ancestors(group: str, parents: dict[str, list[str]]) -> list[str]:
|
|
87
|
+
"""Parent groups, farthest first, so nearer groups override them like Ansible does."""
|
|
88
|
+
levels: dict[str, int] = {}
|
|
89
|
+
frontier = [(group, 0)]
|
|
90
|
+
while frontier:
|
|
91
|
+
current, depth = frontier.pop()
|
|
92
|
+
for parent in parents.get(current, []):
|
|
93
|
+
if parent == group or levels.get(parent, -1) >= depth + 1:
|
|
94
|
+
continue
|
|
95
|
+
if depth + 1 > 64:
|
|
96
|
+
raise ValueError("inventory group nesting is too deep or circular")
|
|
97
|
+
levels[parent] = depth + 1
|
|
98
|
+
frontier.append((parent, depth + 1))
|
|
99
|
+
return sorted(levels, key=lambda name: (-levels[name], name))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_inventory(text: str, source: str | None = None) -> list[InventoryHost]:
|
|
103
|
+
if _looks_like_yaml(text, source):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"only INI inventories are supported in this release. For a YAML inventory, write an INI copy; "
|
|
106
|
+
"'ansible-inventory -i <file> --list' shows each host and its ansible_* connection vars"
|
|
107
|
+
)
|
|
108
|
+
group = "ungrouped"
|
|
109
|
+
kind = "hosts"
|
|
110
|
+
group_vars: dict[str, dict[str, str]] = {"all": {}}
|
|
111
|
+
parents: dict[str, list[str]] = {}
|
|
112
|
+
membership: dict[str, list[str]] = {}
|
|
113
|
+
inline_vars: dict[str, dict[str, str]] = {}
|
|
114
|
+
order: list[str] = []
|
|
115
|
+
|
|
116
|
+
for raw in text.splitlines():
|
|
117
|
+
line = raw.split("#", 1)[0].split(";", 1)[0].strip()
|
|
118
|
+
if not line:
|
|
119
|
+
continue
|
|
120
|
+
if line.startswith("[") and line.endswith("]"):
|
|
121
|
+
header = line[1:-1].strip()
|
|
122
|
+
if header.endswith(":vars"):
|
|
123
|
+
group = header[: -len(":vars")] or "all"
|
|
124
|
+
kind = "vars"
|
|
125
|
+
group_vars.setdefault(group, {})
|
|
126
|
+
elif header.endswith(":children"):
|
|
127
|
+
group = header[: -len(":children")]
|
|
128
|
+
kind = "children"
|
|
129
|
+
group_vars.setdefault(group, {})
|
|
130
|
+
elif ":" in header:
|
|
131
|
+
kind = "skip"
|
|
132
|
+
else:
|
|
133
|
+
group = header or "ungrouped"
|
|
134
|
+
kind = "hosts"
|
|
135
|
+
group_vars.setdefault(group, {})
|
|
136
|
+
continue
|
|
137
|
+
if kind == "skip":
|
|
138
|
+
continue
|
|
139
|
+
if kind == "vars":
|
|
140
|
+
if "=" in line:
|
|
141
|
+
key, value = line.split("=", 1)
|
|
142
|
+
_keep(key, value, group_vars.setdefault(group, {}))
|
|
143
|
+
continue
|
|
144
|
+
if kind == "children":
|
|
145
|
+
child = line.split()[0]
|
|
146
|
+
if not _GROUP.fullmatch(child):
|
|
147
|
+
raise ValueError(f"not a group name: {child}")
|
|
148
|
+
if group not in parents.setdefault(child, []):
|
|
149
|
+
parents[child].append(group)
|
|
150
|
+
continue
|
|
151
|
+
parts = line.split()
|
|
152
|
+
names = _expand(parts[0])
|
|
153
|
+
chosen: dict[str, str] = {}
|
|
154
|
+
for part in parts[1:]:
|
|
155
|
+
if "=" in part:
|
|
156
|
+
key, value = part.split("=", 1)
|
|
157
|
+
_keep(key, value, chosen)
|
|
158
|
+
for name in names:
|
|
159
|
+
if not _NAME.fullmatch(name):
|
|
160
|
+
raise ValueError(f"not a hostname: {name}")
|
|
161
|
+
if name not in inline_vars:
|
|
162
|
+
order.append(name)
|
|
163
|
+
inline_vars[name] = {}
|
|
164
|
+
membership[name] = []
|
|
165
|
+
if group not in membership[name] and group != "ungrouped":
|
|
166
|
+
membership[name].append(group)
|
|
167
|
+
inline_vars[name].update(chosen)
|
|
168
|
+
|
|
169
|
+
hosts: list[InventoryHost] = []
|
|
170
|
+
for name in order:
|
|
171
|
+
merged: dict[str, str] = dict(group_vars.get("all", {}))
|
|
172
|
+
applied: list[str] = []
|
|
173
|
+
for owned in membership[name]:
|
|
174
|
+
for parent in _ancestors(owned, parents):
|
|
175
|
+
if parent not in applied and parent != "all":
|
|
176
|
+
applied.append(parent)
|
|
177
|
+
for owned in membership[name]:
|
|
178
|
+
if owned not in applied:
|
|
179
|
+
applied.append(owned)
|
|
180
|
+
else:
|
|
181
|
+
# A direct group beats the same group reached as a parent.
|
|
182
|
+
applied.remove(owned)
|
|
183
|
+
applied.append(owned)
|
|
184
|
+
for owned in applied:
|
|
185
|
+
merged.update(group_vars.get(owned, {}))
|
|
186
|
+
merged.update(inline_vars[name])
|
|
187
|
+
user = merged.get("user") or None
|
|
188
|
+
if user is not None and not _USER.fullmatch(user):
|
|
189
|
+
raise ValueError(f"{name} has a username that is not usable")
|
|
190
|
+
address = merged.get("host", name)
|
|
191
|
+
if not _NAME.fullmatch(address):
|
|
192
|
+
raise ValueError(f"{name} has an address that is not usable")
|
|
193
|
+
port_text = merged.get("port")
|
|
194
|
+
port = _port(port_text, name) if port_text else None
|
|
195
|
+
identity = merged.get("key") or None
|
|
196
|
+
if identity is not None and (identity.startswith("-") or "{{" in identity):
|
|
197
|
+
raise ValueError(f"{name} has a private-key path Keywarden cannot pass to ssh")
|
|
198
|
+
hosts.append(InventoryHost(name, address, user, port, identity))
|
|
199
|
+
return hosts
|