umbermark-cli 1.0.0rc1__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.
Files changed (93) hide show
  1. umbermark/__init__.py +3 -0
  2. umbermark/checks/__init__.py +88 -0
  3. umbermark/checks/auto_updates_configured.py +149 -0
  4. umbermark/checks/dns_resolver_sanity.py +47 -0
  5. umbermark/checks/fail2ban_enabled.py +67 -0
  6. umbermark/checks/failed_login_anomaly.py +48 -0
  7. umbermark/checks/failed_systemd_units.py +41 -0
  8. umbermark/checks/firewall_protective.py +247 -0
  9. umbermark/checks/journald_persistence.py +55 -0
  10. umbermark/checks/kernel_age.py +74 -0
  11. umbermark/checks/listen_vs_firewall_delta.py +136 -0
  12. umbermark/checks/mac_framework_enforcing.py +97 -0
  13. umbermark/checks/pending_security_updates.py +188 -0
  14. umbermark/checks/public_listen_anomalies.py +200 -0
  15. umbermark/checks/reboot_pending.py +71 -0
  16. umbermark/checks/repo_gpg_trust.py +97 -0
  17. umbermark/checks/root_disk_free.py +46 -0
  18. umbermark/checks/ssh_non_default_port.py +62 -0
  19. umbermark/checks/ssh_password_auth.py +71 -0
  20. umbermark/checks/ssh_permit_root.py +59 -0
  21. umbermark/checks/ssh_weak_ciphers.py +85 -0
  22. umbermark/checks/sudo_requires_authentication.py +41 -0
  23. umbermark/checks/sudoers_nopasswd.py +111 -0
  24. umbermark/checks/swap_pressure.py +51 -0
  25. umbermark/checks/time_sync.py +55 -0
  26. umbermark/checks/tmp_hardening.py +45 -0
  27. umbermark/checks/user_password_aging.py +95 -0
  28. umbermark/checks/world_writable_suid_audit.py +171 -0
  29. umbermark/cli/__init__.py +4 -0
  30. umbermark/cli/app.py +23 -0
  31. umbermark/cli/auth.py +44 -0
  32. umbermark/cli/commands/__init__.py +0 -0
  33. umbermark/cli/commands/doctor.py +16 -0
  34. umbermark/cli/commands/login.py +80 -0
  35. umbermark/cli/commands/push.py +54 -0
  36. umbermark/cli/commands/report.py +26 -0
  37. umbermark/cli/commands/scan.py +77 -0
  38. umbermark/cli/commands/scan_local.py +72 -0
  39. umbermark/cli/commands/scan_wsl.py +35 -0
  40. umbermark/cli/commands/version.py +11 -0
  41. umbermark/cli/config.py +13 -0
  42. umbermark/core/__init__.py +0 -0
  43. umbermark/core/accepted.py +61 -0
  44. umbermark/core/access.py +896 -0
  45. umbermark/core/capture.py +228 -0
  46. umbermark/core/check.py +82 -0
  47. umbermark/core/checkin.py +89 -0
  48. umbermark/core/crypto.py +15 -0
  49. umbermark/core/duration.py +45 -0
  50. umbermark/core/engine.py +74 -0
  51. umbermark/core/external/__init__.py +6 -0
  52. umbermark/core/external/check.py +54 -0
  53. umbermark/core/external/checks/__init__.py +31 -0
  54. umbermark/core/external/checks/exposed_datastores.py +55 -0
  55. umbermark/core/external/checks/http_security_headers.py +249 -0
  56. umbermark/core/external/checks/legacy_services.py +52 -0
  57. umbermark/core/external/checks/open_management_ports.py +65 -0
  58. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  59. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  60. umbermark/core/external/checks/tls_hygiene.py +107 -0
  61. umbermark/core/external/engine.py +89 -0
  62. umbermark/core/external/ownership.py +607 -0
  63. umbermark/core/external/probe.py +103 -0
  64. umbermark/core/external/socket_probe.py +400 -0
  65. umbermark/core/firewall.py +713 -0
  66. umbermark/core/fleet.py +92 -0
  67. umbermark/core/history.py +235 -0
  68. umbermark/core/html_report.py +306 -0
  69. umbermark/core/keyenc.py +42 -0
  70. umbermark/core/labels.py +29 -0
  71. umbermark/core/lifecycle.py +100 -0
  72. umbermark/core/logredact.py +44 -0
  73. umbermark/core/partial.py +57 -0
  74. umbermark/core/pdf/__init__.py +0 -0
  75. umbermark/core/pdf/renderer.py +26 -0
  76. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  77. umbermark/core/platform.py +73 -0
  78. umbermark/core/redact.py +75 -0
  79. umbermark/core/report.py +25 -0
  80. umbermark/core/scoring.py +105 -0
  81. umbermark/core/settings.py +238 -0
  82. umbermark/core/short_id.py +27 -0
  83. umbermark/core/signing.py +24 -0
  84. umbermark/core/ssh.py +156 -0
  85. umbermark/core/ssh_fingerprint.py +16 -0
  86. umbermark/core/tiers.py +128 -0
  87. umbermark/core/webhooks.py +177 -0
  88. umbermark/core/wording.py +371 -0
  89. umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
  90. umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
  91. umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  92. umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  93. umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,177 @@
1
+ """Outbound alert webhooks: URL validation, SSRF-safe delivery, message bodies (UMBR-66 alert channels).
2
+
3
+ Three kinds of destination, all customer-configured:
4
+ webhook any https URL; gets JSON, signed with HMAC-SHA256 (X-Umbermark-Signature: sha256=<hex>)
5
+ slack https://hooks.slack.com/services/... ({"text": ...})
6
+ discord https://discord.com/api/webhooks/... ({"content": ...}; discordapp.com accepted)
7
+
8
+ SSRF rules (every send, not only on save):
9
+ * https only, port 443 only for generic webhooks, no credentials in the URL, no IP-literal hosts;
10
+ * the host must resolve and EVERY address must be public (core.external.ownership.resolve_public_ips refuses
11
+ loopback, RFC1918, CGNAT/tailnet, link-local/metadata, multicast and the rest);
12
+ * the TCP connection goes to the address that was checked (pinned), with SNI and certificate checks against
13
+ the hostname, so a DNS answer cannot change between the check and the connect;
14
+ * redirects are never followed (a 3xx counts as a failed delivery);
15
+ * short timeouts and a capped response read.
16
+ The host's egress firewall (deploy/umk-egress.nft) blocks the same ranges for the app's uid as a second layer.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import http.client
23
+ import json
24
+ import socket
25
+ import ssl
26
+ from dataclasses import dataclass
27
+ from urllib.parse import urlsplit
28
+
29
+ KINDS: tuple[str, ...] = ("webhook", "slack", "discord")
30
+ KIND_LABELS = {"webhook": "Webhook (JSON)", "slack": "Slack", "discord": "Discord"}
31
+ MAX_URL_LEN = 500
32
+ TIMEOUT_SECONDS = 8
33
+ _SLACK_HOSTS = {"hooks.slack.com"}
34
+ _DISCORD_HOSTS = {"discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com"}
35
+
36
+
37
+ class WebhookError(ValueError):
38
+ """The URL is not acceptable, or delivery was refused before any bytes were sent."""
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Target:
43
+ host: str
44
+ port: int
45
+ path: str # path + query, as sent
46
+
47
+
48
+ def validate(kind: str, url: str) -> Target:
49
+ """Shape checks only (no DNS). Raises WebhookError with a message fit for the form."""
50
+ if kind not in KINDS:
51
+ raise WebhookError("Choose webhook, Slack or Discord")
52
+ url = (url or "").strip()
53
+ if not url or len(url) > MAX_URL_LEN:
54
+ raise WebhookError(f"Paste the full https URL (at most {MAX_URL_LEN} characters)")
55
+ try:
56
+ u = urlsplit(url)
57
+ port = u.port or 443
58
+ except ValueError as exc:
59
+ raise WebhookError("That is not a valid URL") from exc
60
+ host = (u.hostname or "").lower().rstrip(".")
61
+ if u.scheme != "https" or not host:
62
+ raise WebhookError("The URL must start with https://")
63
+ if u.username or u.password:
64
+ raise WebhookError("The URL must not contain a user name or password")
65
+ if port != 443:
66
+ raise WebhookError("Only the standard https port (443) is allowed")
67
+ if _is_ip_literal(host):
68
+ raise WebhookError("Use a host name, not an IP address")
69
+ path = (u.path or "/") + (f"?{u.query}" if u.query else "")
70
+ if kind == "slack" and (host not in _SLACK_HOSTS or not u.path.startswith("/services/")):
71
+ raise WebhookError("A Slack incoming-webhook URL starts with https://hooks.slack.com/services/")
72
+ if kind == "discord" and (host not in _DISCORD_HOSTS or not u.path.startswith("/api/webhooks/")):
73
+ raise WebhookError("A Discord webhook URL starts with https://discord.com/api/webhooks/")
74
+ return Target(host, port, path)
75
+
76
+
77
+ def masked(url: str) -> str:
78
+ """What the page shows: scheme, host and the last 4 characters (the rest of a Slack/Discord URL is a secret)."""
79
+ try:
80
+ u = urlsplit(url)
81
+ tail = url[-4:] if len(url) > 12 else ""
82
+ return f"https://{u.hostname}/…{tail}"
83
+ except ValueError:
84
+ return "(invalid URL)"
85
+
86
+
87
+ def _is_ip_literal(host: str) -> bool:
88
+ import ipaddress
89
+
90
+ try:
91
+ ipaddress.ip_address(host.strip("[]"))
92
+ return True
93
+ except ValueError:
94
+ return False
95
+
96
+
97
+ def _resolve(host: str) -> str:
98
+ """The first public address of `host`; WebhookError unless every address is public."""
99
+ from umbermark.core.external.ownership import BlockedTarget, resolve_public_ips
100
+
101
+ try:
102
+ return resolve_public_ips(host)[0]
103
+ except BlockedTarget as exc:
104
+ raise WebhookError("That host does not resolve to a public address") from exc
105
+
106
+
107
+ class _PinnedHTTPS(http.client.HTTPSConnection):
108
+ """HTTPS to a fixed IP while SNI and the certificate check use the hostname."""
109
+
110
+ def __init__(self, host: str, ip: str, port: int, timeout: float):
111
+ super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
112
+ self._ip = ip
113
+
114
+ def connect(self): # noqa: D401 - http.client hook
115
+ sock = socket.create_connection((self._ip, self.port), self.timeout)
116
+ self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
117
+
118
+
119
+ def body_for(kind: str, event: dict) -> bytes:
120
+ """The request body for one alert event (see `drop_event`)."""
121
+ text = event["text"]
122
+ if kind == "slack":
123
+ return json.dumps({"text": text}).encode()
124
+ if kind == "discord":
125
+ return json.dumps({"content": text[:1900], "allowed_mentions": {"parse": []}}).encode()
126
+ return json.dumps({k: v for k, v in event.items() if k != "text"} | {"text": text}, sort_keys=True).encode()
127
+
128
+
129
+ def signature(secret: str, body: bytes) -> str:
130
+ return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
131
+
132
+
133
+ def send(kind: str, url: str, event: dict, *, secret: str | None = None, resolve=None,
134
+ connection_factory=None) -> int:
135
+ """Deliver one event. Returns the HTTP status (2xx = delivered). Raises WebhookError before sending when the
136
+ URL or its addresses are not acceptable; OSError/ssl errors propagate for the caller to record."""
137
+ t = validate(kind, url)
138
+ ip = (resolve or _resolve)(t.host)
139
+ connection_factory = connection_factory or _PinnedHTTPS
140
+ body = body_for(kind, event)
141
+ headers = {"Content-Type": "application/json", "User-Agent": "Umbermark-Alerts/1"}
142
+ if kind == "webhook" and secret:
143
+ headers["X-Umbermark-Signature"] = signature(secret, body)
144
+ conn = connection_factory(t.host, ip, t.port, TIMEOUT_SECONDS)
145
+ try:
146
+ conn.request("POST", t.path, body=body, headers=headers)
147
+ resp = conn.getresponse()
148
+ resp.read(4096) # capped; never kept
149
+ return resp.status
150
+ finally:
151
+ conn.close()
152
+
153
+
154
+ def drop_event(*, label: str, server_id: int, previous: int, current: int, threshold: int, report_url: str) -> dict:
155
+ """The one alert Umbermark sends today: a score drop at or past the server's threshold."""
156
+ delta = previous - current
157
+ return {
158
+ "event": "score_drop", "server": {"id": server_id, "label": label},
159
+ "score": {"previous": previous, "current": current, "drop": delta, "threshold": threshold},
160
+ "report_url": report_url,
161
+ "text": f"Umbermark: {label} score dropped {delta} points ({previous} -> {current}). {report_url}",
162
+ }
163
+
164
+
165
+ def missed_event(*, label: str, server_id: int, last_checkin_at: str | None, server_url: str) -> dict:
166
+ """A check-in server did not check in within its interval plus grace (UMBR-66)."""
167
+ last = (last_checkin_at or "never")[:16].replace("T", " ")
168
+ return {
169
+ "event": "missed_checkin", "server": {"id": server_id, "label": label}, "score": None,
170
+ "last_checkin_at": last_checkin_at, "report_url": server_url,
171
+ "text": f"Umbermark: {label} missed its scheduled check-in (last check-in: {last} UTC). {server_url}",
172
+ }
173
+
174
+
175
+ def sample_event(label: str = "test") -> dict:
176
+ return {"event": "test", "server": None, "score": None, "report_url": None,
177
+ "text": f"Umbermark: test alert for channel '{label}'. If you can read this, alerts reach you."}
@@ -0,0 +1,371 @@
1
+ """Report wording: how a check's result reads to a human.
2
+
3
+ A check has three strings, kept apart from the check classes so existing
4
+ stored reports (which only carry ``check_id`` + the old ``title``) pick up new
5
+ wording at render time with no migration:
6
+
7
+ * ``control`` neutral name of the thing being checked ("SSH cipher strength").
8
+ Used on SEO pages, the checks index and anywhere no verdict is
9
+ implied.
10
+ * ``pass_text`` what is right on the server (shown on PASS rows).
11
+ * ``fail_text`` what is wrong on the server (shown on FAIL / WARN rows and in
12
+ "What cost you points"). Present tense, no "should".
13
+
14
+ Rule of thumb: ``fail_text`` is a finding, never a control name. A reader who
15
+ only sees the fail text must never think the *good* thing is the problem.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime as dt
20
+ import html
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ from markupsafe import Markup
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Wording:
29
+ control: str
30
+ pass_text: str
31
+ fail_text: str
32
+
33
+
34
+ WORDING: dict[str, Wording] = {
35
+ # --- identity / auth ---------------------------------------------------
36
+ "ssh_permit_root_login": Wording(
37
+ "SSH root login",
38
+ "SSH does not allow direct root login",
39
+ "SSH allows direct root login",
40
+ ),
41
+ "ssh_password_authentication": Wording(
42
+ "SSH password login",
43
+ "SSH only accepts keys; password login is off",
44
+ "SSH still accepts password logins",
45
+ ),
46
+ "ssh_weak_ciphers": Wording(
47
+ "SSH cipher strength",
48
+ "SSH only accepts strong ciphers, MACs and key exchange",
49
+ "SSH still accepts weak ciphers, MACs or key exchange",
50
+ ),
51
+ "ssh_non_default_port": Wording(
52
+ "SSH port",
53
+ "SSH listens on a non-default port",
54
+ "SSH listens on the default port 22",
55
+ ),
56
+ "sudoers_nopasswd": Wording(
57
+ "Passwordless sudo rules",
58
+ "No risky passwordless sudo rules",
59
+ "Passwordless sudo (NOPASSWD) rules are in place",
60
+ ),
61
+ "sudo_requires_authentication": Wording(
62
+ "sudo authentication",
63
+ "sudo asks users to authenticate",
64
+ "sudo lets users in without authenticating",
65
+ ),
66
+ "user_password_aging": Wording(
67
+ "Password aging policy",
68
+ "Account passwords have an expiry policy",
69
+ "Some accounts have passwords that never expire",
70
+ ),
71
+ "failed_login_anomaly": Wording(
72
+ "Failed SSH logins (7 days)",
73
+ "Few failed SSH logins in the last 7 days",
74
+ "Many failed SSH login attempts in the last 7 days",
75
+ ),
76
+ "fail2ban_enabled": Wording(
77
+ "fail2ban brute-force protection",
78
+ "fail2ban is running with active jails",
79
+ "fail2ban is missing, stopped or has no active jails",
80
+ ),
81
+ # --- patching ----------------------------------------------------------
82
+ "kernel_age": Wording(
83
+ "Kernel age",
84
+ "The running kernel is recent",
85
+ "The running kernel is more than 6 months old",
86
+ ),
87
+ "pending_security_updates": Wording(
88
+ "Security updates",
89
+ "No security updates are waiting",
90
+ "Security updates are waiting to be installed",
91
+ ),
92
+ "reboot_pending": Wording(
93
+ "Pending reboot",
94
+ "No reboot is pending",
95
+ "A reboot is pending, so updates are not active yet",
96
+ ),
97
+ "auto_updates_configured": Wording(
98
+ "Automatic security updates",
99
+ "Automatic security updates are turned on",
100
+ "Automatic security updates are not set up",
101
+ ),
102
+ "repo_gpg_trust": Wording(
103
+ "Package signing keys",
104
+ "Package repositories have signing keys configured",
105
+ "Package repositories have no signing keys configured",
106
+ ),
107
+ # --- network -----------------------------------------------------------
108
+ "firewall_protective": Wording(
109
+ "Host firewall",
110
+ "The host firewall blocks unsolicited inbound traffic",
111
+ "The host firewall is off or lets inbound traffic through",
112
+ ),
113
+ "time_sync": Wording(
114
+ "Clock synchronisation",
115
+ "The clock is synced and an NTP service is running",
116
+ "The clock is not synced or no NTP service is running",
117
+ ),
118
+ "dns_resolver_sanity": Wording(
119
+ "DNS resolver",
120
+ "DNS is configured and answering",
121
+ "DNS is misconfigured or not answering",
122
+ ),
123
+ # --- filesystem / services / logging / hardening -----------------------
124
+ "root_disk_free": Wording(
125
+ "Root disk space",
126
+ "The root disk has plenty of free space",
127
+ "The root disk is nearly full",
128
+ ),
129
+ "tmp_hardening": Wording(
130
+ "/tmp mount options",
131
+ "/tmp is mounted noexec and nosuid",
132
+ "/tmp allows running programs or setuid files",
133
+ ),
134
+ "failed_systemd_units": Wording(
135
+ "Failed systemd units",
136
+ "No systemd units have failed",
137
+ "One or more systemd units have failed",
138
+ ),
139
+ "swap_pressure": Wording(
140
+ "Swap pressure",
141
+ "The server is not swapping memory to disk",
142
+ "The server is swapping memory to disk",
143
+ ),
144
+ "journald_persistence": Wording(
145
+ "Persistent system logs",
146
+ "System logs survive a reboot",
147
+ "System logs are lost on reboot",
148
+ ),
149
+ "mac_framework_enforcing": Wording(
150
+ "AppArmor / SELinux",
151
+ "AppArmor or SELinux is enforcing",
152
+ "AppArmor or SELinux is off or not enforcing",
153
+ ),
154
+ "public_listen_anomalies": Wording(
155
+ "Public listening ports",
156
+ "Only expected ports listen on public addresses",
157
+ "Unexpected ports listen on public addresses",
158
+ ),
159
+ "listen_vs_firewall_delta": Wording(
160
+ "Firewall cover for listening ports",
161
+ "Public listening ports are covered by a deny-by-default firewall",
162
+ "Public ports are reachable only because the firewall allows everything by default",
163
+ ),
164
+ "world_writable_suid_audit": Wording(
165
+ "World-writable files and SUID binaries",
166
+ "No unexpected world-writable files or SUID binaries",
167
+ "Unexpected world-writable files or SUID binaries exist",
168
+ ),
169
+ # --- external (outside-in) --------------------------------------------
170
+ "external_exposed_datastores": Wording(
171
+ "Exposed data stores",
172
+ "No database or cache is reachable from the internet",
173
+ "A database or cache is reachable from the internet",
174
+ ),
175
+ "external_legacy_plaintext_services": Wording(
176
+ "Legacy plaintext services",
177
+ "No telnet, FTP or r-services are reachable from the internet",
178
+ "A plaintext service (telnet, FTP or r-service) is reachable from the internet",
179
+ ),
180
+ "external_open_management_ports": Wording(
181
+ "Remote-management ports",
182
+ "No remote-management ports are reachable from the internet",
183
+ "Remote-management ports (RDP, VNC, Docker, etcd...) are reachable from the internet",
184
+ ),
185
+ "external_ssh_weak_algorithms": Wording(
186
+ "SSH algorithms (outside view)",
187
+ "SSH offers only modern algorithms to the internet",
188
+ "SSH offers weak or deprecated algorithms to the internet",
189
+ ),
190
+ "external_ssh_alt_ports": Wording(
191
+ "SSH algorithms on alternate ports (outside view)",
192
+ "SSH on alternate ports offers only modern algorithms to the internet",
193
+ "SSH on an alternate port offers weak or deprecated algorithms to the internet",
194
+ ),
195
+ "external_tls_hygiene": Wording(
196
+ "TLS and certificate",
197
+ "TLS is modern and the certificate is valid",
198
+ "TLS uses old protocols or the certificate has a problem",
199
+ ),
200
+ "external_http_security_headers": Wording(
201
+ "HTTP security headers",
202
+ "HTTPS responses set the core security headers",
203
+ "HTTPS responses are missing or weakly set core security headers",
204
+ ),
205
+ }
206
+
207
+
208
+ def wording_for(check_id: str, fallback_title: str = "") -> Wording:
209
+ """Wording for a check; unknown ids (old/foreign payloads) fall back to the
210
+ stored title for all three strings."""
211
+ w = WORDING.get(check_id)
212
+ if w is not None:
213
+ return w
214
+ t = fallback_title or check_id
215
+ return Wording(t, t, t)
216
+
217
+
218
+ def control_name(check_id: str, fallback_title: str = "") -> str:
219
+ return wording_for(check_id, fallback_title).control
220
+
221
+
222
+ # --- skip reasons -----------------------------------------------------------
223
+
224
+ def skip_note(reason: str) -> str:
225
+ """Public-safe, human reason a check was skipped (no raw command output)."""
226
+ r = (reason or "").lower()
227
+ if not r:
228
+ return "Skipped: no data was available for this check."
229
+ if "ownership" in r or "re-verify" in r or "not probed (unverified)" in r:
230
+ return "Skipped: this check only runs after you verify you own the server."
231
+ if "not applicable" in r or "no protective firewall" in r or "non-systemd" in r:
232
+ return "Skipped: not applicable to this server."
233
+ if "check raised" in r:
234
+ return "Skipped: the check hit an internal error."
235
+ if "requires root" in r or "needs root" in r:
236
+ return "Skipped: needs root access, which this scan did not have."
237
+ if "no tls service" in r or "no https service" in r or "kexinit" in r:
238
+ return "Skipped: the service did not answer on the expected port."
239
+ return "Skipped: the data was not available on this server."
240
+
241
+
242
+ # --- presentation -------------------------------------------------------------
243
+
244
+ def points_lost(verdict: str, weight: int) -> int:
245
+ if verdict == "fail":
246
+ return int(weight)
247
+ if verdict == "warn":
248
+ return int(weight) // 2
249
+ return 0
250
+
251
+
252
+ def _val(x):
253
+ return x.value if hasattr(x, "value") else x
254
+
255
+
256
+ def annotate_finding(f: dict) -> dict:
257
+ """Add display fields to a finding dict (in place) and return it."""
258
+ verdict = str(_val(f.get("verdict", "")))
259
+ w = wording_for(f.get("check_id", ""), f.get("title", ""))
260
+ f["control"] = w.control
261
+ if verdict == "pass":
262
+ f["text"] = w.pass_text
263
+ elif verdict in ("fail", "warn"):
264
+ f["text"] = w.fail_text
265
+ else:
266
+ f["text"] = w.control
267
+ lost = points_lost(verdict, f.get("weight", 0))
268
+ f["points_lost"] = lost
269
+ f["informational"] = verdict == "warn" and lost == 0
270
+ if verdict == "skip":
271
+ f["skip_note"] = f.get("skip_note") or skip_note(f.get("skip_reason", ""))
272
+ f["remediation_html"] = remediation_html(f.get("remediation", ""))
273
+ return f
274
+
275
+
276
+ def present_payload(payload: dict) -> dict:
277
+ """Return the payload with display fields added to findings and the
278
+ deduction list split into points-costing vs informational (0-point) rows."""
279
+ findings = [annotate_finding(dict(f)) for f in payload.get("findings", [])]
280
+ out = dict(payload)
281
+ out["findings"] = findings
282
+ by_id = {f.get("check_id"): f for f in findings}
283
+ cost, info = [], []
284
+ for d in payload.get("result", {}).get("deductions", []):
285
+ d = dict(d)
286
+ f = by_id.get(d.get("check_id"))
287
+ d["text"] = f["text"] if f else wording_for(d.get("check_id", ""), d.get("title", "")).fail_text
288
+ (cost if d.get("points_lost", 0) > 0 else info).append(d)
289
+ # Zero-point findings that never produced a Deduction row (e.g. weight 0).
290
+ seen = {d.get("check_id") for d in cost + info}
291
+ for f in findings:
292
+ if f["informational"] and f.get("check_id") not in seen:
293
+ info.append({"check_id": f.get("check_id"), "severity": _val(f.get("severity", "")),
294
+ "verdict": "warn", "points_lost": 0, "text": f["text"]})
295
+ out["cost_deductions"] = cost
296
+ out["info_deductions"] = info
297
+ return out
298
+
299
+
300
+ # --- remediation text -> safe HTML -------------------------------------------
301
+
302
+ _BULLET = re.compile(r"^\s{1,3}(?:-|\d+\.)\s+(.*)$")
303
+ _BACKTICK = re.compile(r"`([^`]+)`")
304
+
305
+
306
+ def _inline(s: str) -> str:
307
+ return _BACKTICK.sub(lambda m: "<code>" + m.group(1) + "</code>", html.escape(s, quote=False))
308
+
309
+
310
+ def remediation_html(text: str) -> Markup:
311
+ """Render a remediation string: indented (4+ spaces) lines become <pre><code>
312
+ blocks, " - " / " 1. " lines become list items, hard-wrapped prose lines
313
+ are joined into paragraphs, `x` becomes <code>x</code>. Everything is
314
+ HTML-escaped first."""
315
+ if not text:
316
+ return Markup("")
317
+ out: list[str] = []
318
+ para: list[str] = []
319
+ code: list[str] = []
320
+ items: list[str] = []
321
+
322
+ def flush() -> None:
323
+ if para:
324
+ out.append("<p>" + _inline(" ".join(para)) + "</p>")
325
+ para.clear()
326
+ if code:
327
+ out.append("<pre><code>" + html.escape("\n".join(code), quote=False) + "</code></pre>")
328
+ code.clear()
329
+ if items:
330
+ out.append("<ul>" + "".join("<li>" + _inline(i) + "</li>" for i in items) + "</ul>")
331
+ items.clear()
332
+
333
+ for raw in text.split("\n"):
334
+ line = raw.rstrip()
335
+ if not line.strip():
336
+ flush()
337
+ continue
338
+ indent = len(line) - len(line.lstrip(" "))
339
+ b = _BULLET.match(line) if indent < 4 else None
340
+ if b:
341
+ if para or code:
342
+ flush()
343
+ items.append(b.group(1))
344
+ continue
345
+ if items and indent >= 2: # wrapped continuation of a bullet
346
+ items[-1] += " " + line.strip()
347
+ continue
348
+ if indent >= 4:
349
+ if para or items:
350
+ flush()
351
+ code.append(line[4:])
352
+ continue
353
+ if code or items:
354
+ flush()
355
+ para.append(line.strip())
356
+ flush()
357
+ return Markup("".join(out))
358
+
359
+
360
+ # --- dates ----------------------------------------------------------------
361
+
362
+ def human_date(iso: str | None) -> str:
363
+ """'2026-11-21T10:00:00.123456+00:00' -> '21 Nov 2026'. Unparseable input is
364
+ returned unchanged so we never hide a value."""
365
+ if not iso:
366
+ return ""
367
+ try:
368
+ d = dt.datetime.fromisoformat(iso)
369
+ except ValueError:
370
+ return iso
371
+ return f"{d.day} {d.strftime('%b %Y')}"
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: umbermark-cli
3
+ Version: 1.0.0rc1
4
+ Summary: Umbermark — Linux server hardening scanner (CLI only).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: paramiko>=3.4
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pynacl>=1.5
11
+ Requires-Dist: itsdangerous>=2.2
@@ -0,0 +1,93 @@
1
+ umbermark/__init__.py,sha256=f15smPp8ezk4d4DQ77dhLtpHz1Vzyz1i8AE-bmxsvvQ,75
2
+ umbermark/checks/__init__.py,sha256=meATU7ey2y7yN1Z34X6nnyB1V4IEQkHD8BlqMsXv_kI,3536
3
+ umbermark/checks/auto_updates_configured.py,sha256=SGUeKPIPzZmSOVkUmXPoe-6dnQXQEAiXFNM75zub_OY,7226
4
+ umbermark/checks/dns_resolver_sanity.py,sha256=pkvlq7AOU1c52YoJRjrJG-tHMMKKvAcrNTlnu6DUHV4,2043
5
+ umbermark/checks/fail2ban_enabled.py,sha256=bXTY1sVCaeLTNodT1ef5lpnts5lgBl3f8Ezpyf1HUu0,3131
6
+ umbermark/checks/failed_login_anomaly.py,sha256=wyL6xFVSKQnDhQ2GMnR80ocCMoIDjDzJOiql7whp7_Q,1978
7
+ umbermark/checks/failed_systemd_units.py,sha256=xQhn00oF1HqpN0Is0n0GIZURG-H7D4kc2xucmiJrmQE,1524
8
+ umbermark/checks/firewall_protective.py,sha256=JjT8SlTyo-suTa3DyicCLm3MkpiOhcH_3JXopq9GH0k,11349
9
+ umbermark/checks/journald_persistence.py,sha256=QXDdz6sqgoUFtxLEbgiImqV1oaGh2pODbw4K3Ua6Hbw,2255
10
+ umbermark/checks/kernel_age.py,sha256=RaPDg4OTdE92qF-3Xf8RzzgilYPHHO1-X1QSPqrM2TA,2753
11
+ umbermark/checks/listen_vs_firewall_delta.py,sha256=mXCrJNGHxoYyZ5TOjsPkwaL2ysbXMvLehLgmQY8XTWU,6045
12
+ umbermark/checks/mac_framework_enforcing.py,sha256=uGFam024Le7Q2uYssaVfaq6CIWs8TW_J95PpnsvDlDI,4385
13
+ umbermark/checks/pending_security_updates.py,sha256=vriHpGaHn-T46LkPm8AVEJjrhlKORqPYzKabbibdMO0,9023
14
+ umbermark/checks/public_listen_anomalies.py,sha256=fLLhR0201bYS2061BOz1mpuMsaPE6ubGDJ1881Zc7cA,8953
15
+ umbermark/checks/reboot_pending.py,sha256=7sWI-Bu6LdFuaRiMn9xJIg2hemtqZHzxrv2jPyv4ZPc,3372
16
+ umbermark/checks/repo_gpg_trust.py,sha256=tV1F7JfCPdZQ6f3P3Hs1W1_CMF-OsBVFzYEHAe5U0G8,4405
17
+ umbermark/checks/root_disk_free.py,sha256=Wl-yAgsK3KwQKRf7L60iz2YE0-t5oTlge51zdimbTF8,1967
18
+ umbermark/checks/ssh_non_default_port.py,sha256=0poTmvUcsZX3gygetFV0O-dDhttw-PIf2Ni10xQ3IQk,2382
19
+ umbermark/checks/ssh_password_auth.py,sha256=NpH1Z6jxWUMkISRwS0dDWMHnkJtT0UJtOeeXDWA_zjE,3516
20
+ umbermark/checks/ssh_permit_root.py,sha256=995A5PvpVkBGbkOF-g9GEKqnNLDrK2rEe7AwryBj6qo,2304
21
+ umbermark/checks/ssh_weak_ciphers.py,sha256=9zrI3baYNeVb30gNoluovSF41gXxlRGt1FMh1hpRuX4,3419
22
+ umbermark/checks/sudo_requires_authentication.py,sha256=h4ssUIVQMRfqHgykuxHb2zcANj0HA-jOfxVUV8VFyZI,2252
23
+ umbermark/checks/sudoers_nopasswd.py,sha256=EGlsZIY_wrXJhWKieCKXhfhoRAyq8MbHWSdWfchxrn0,4807
24
+ umbermark/checks/swap_pressure.py,sha256=aW2ulos4RL1KEJiW5vwnnZLpiD_waSeElrBhjoBdzsI,2046
25
+ umbermark/checks/time_sync.py,sha256=QvXQTl1Va7sGfs0uXXc-vhf_qYIqzWGHO1-BLuY3BwQ,2138
26
+ umbermark/checks/tmp_hardening.py,sha256=OXPGKUJvyL0MtgFpDngKf4QDdx6ab0opJ1bvjQxTaQY,1693
27
+ umbermark/checks/user_password_aging.py,sha256=PV-6yzxGn59S-eTGlQaW1f8Kdz7SZ0-Grc2HG0hcVWI,4139
28
+ umbermark/checks/world_writable_suid_audit.py,sha256=_6jP5MvdCKZqUNLWi6DfTJWT2U5vuq0BP_vz7Blep9A,7969
29
+ umbermark/cli/__init__.py,sha256=OafcDkXjKNGtcm9Jq94td3ziRxmSomi4eyuJ-kDWS0w,82
30
+ umbermark/cli/app.py,sha256=zcrMgD8WRs3xq1KTR4AwOkvrHGKXx1kNO7aeOilcuqE,982
31
+ umbermark/cli/auth.py,sha256=tVHU6A0BWN7ZS12hrcmbQGv5m6vBHLl5NQXsOZc4lBE,1322
32
+ umbermark/cli/config.py,sha256=b-8KF6-LtiXVfEi-WPFuMs2Tb2sCAt3Q8xqpm6TeKD8,386
33
+ umbermark/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ umbermark/cli/commands/doctor.py,sha256=Z4RLfwHawKAkgetjyjYX0ndL5zs9LO5P7KAQ9YHGVSM,527
35
+ umbermark/cli/commands/login.py,sha256=jKFnVjj-2r7JImPXhpCF_unS3WXgivbJeYHnJykYr04,2623
36
+ umbermark/cli/commands/push.py,sha256=ykyiWgNaG7abwrTpkFRr53otPvODJMUS3n74lp_okik,1591
37
+ umbermark/cli/commands/report.py,sha256=OqaVZj7Msux0oGg0B8vrjUsZGTecL-9nO7FzzSS2LUI,825
38
+ umbermark/cli/commands/scan.py,sha256=zVrqksoDMqE-7b5873oPlyPqpyV0VSQE9hP_nD3RXKU,2395
39
+ umbermark/cli/commands/scan_local.py,sha256=fnypb-9iceBR-Ly6_3MywIZIBSYKfkCT1YO3qlTt59k,2212
40
+ umbermark/cli/commands/scan_wsl.py,sha256=P-0pi3YwvtKEHf2kzuNYs9yxV7r64-p-BQpa7QwXfSA,1021
41
+ umbermark/cli/commands/version.py,sha256=H3LVWwojlQp6Sc8vxquSxf89yCCExcTfirmqnWloNf8,249
42
+ umbermark/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
+ umbermark/core/accepted.py,sha256=YTfmNw3zVQpderKyje-Fbm8fyAKTGAnrz8pktSU4Z8U,2783
44
+ umbermark/core/access.py,sha256=8FogW5EfcLaec3sD5ZkQjjG2H7WUW0fOQ0VJyOwWi9A,39801
45
+ umbermark/core/capture.py,sha256=MnD3cLC3eIaMxT6pt-JSm9V136i7jp38WdL0R_w9j0M,9208
46
+ umbermark/core/check.py,sha256=xgpGu6wBwycBk4KrgKOHzza7JwLLn4rEUNPbjLPPw20,2289
47
+ umbermark/core/checkin.py,sha256=YZKHU6uxrB2cPZy2NFWkUFIBXm0RmSETwg2Ow8rKByw,4073
48
+ umbermark/core/crypto.py,sha256=FioGtwhLM61hZvA__L9mNN7vVfPtqGUTGnO4PFCpdGI,552
49
+ umbermark/core/duration.py,sha256=9ArbjuIGhrYxI9B6jkr4_HBC6Rw9YANe_D1GDumoMG8,1704
50
+ umbermark/core/engine.py,sha256=NvpQlfjFKa_S76_g6NZ4bUbL5TS8QWDJ3ubr7JpEzvA,2434
51
+ umbermark/core/firewall.py,sha256=phz079QyUBglp_-4BsCQvS7xfJLiX5jocAGDSPjVjrc,28651
52
+ umbermark/core/fleet.py,sha256=CJoqoUmoBBwxkE8ppIxH2fWOtArKjq9r8DcUZ_fQkQs,3747
53
+ umbermark/core/history.py,sha256=qCNbRdijXXBNwoCG-IwOqcrOoKXALMUevxIQ5t5aeRY,9449
54
+ umbermark/core/html_report.py,sha256=KZXirarag99yuZJROJWY_i7WYV70J5J9uHvXimwYD-o,11826
55
+ umbermark/core/keyenc.py,sha256=8xocjqYFR8zKhX1g455ZW2LtNYxrjQgHFFvGHhnb6uk,1464
56
+ umbermark/core/labels.py,sha256=Ir_JH2dSq3uGKLP3tTl6xr-QNHWhNexmHHpUMYtn-pc,844
57
+ umbermark/core/lifecycle.py,sha256=oBilfueGuqL3YiLjW4rlxEEplRABCY000KBJK7rRmYk,3975
58
+ umbermark/core/logredact.py,sha256=fj9FJ-E5c_9DR3AaVi7HbgfaFZe3O9SZpsAApQJ62mA,1523
59
+ umbermark/core/partial.py,sha256=OkfQuMjJYUJBdigNXwf-j_XcazhRpoKBTfVlkXrDWNM,2232
60
+ umbermark/core/platform.py,sha256=QRY5Yzz1t4YlNUSgqd8n-C_fDvrOBNe0VNP-y1R8Nck,2355
61
+ umbermark/core/redact.py,sha256=LesaIucpdiNKl5JAxsIhwuF-ayyuDWn2-OjUK-9J0Nc,3159
62
+ umbermark/core/report.py,sha256=UM9VDYHivLTuNmYFGFreosgtjAIh5d3QitecxyAET60,746
63
+ umbermark/core/scoring.py,sha256=vDTeaHp8mqZKBofs2xIJ40P7v_FdSXZv-kSQogl7EZM,2833
64
+ umbermark/core/settings.py,sha256=BIp83Cfp5dwmHjxCzHwkvPg0CVGsypp65Y0vy8JPooQ,12542
65
+ umbermark/core/short_id.py,sha256=LFRLtScYRgilCa1EEnnjdVJNImBaA7-1QxOnMH4I4OQ,817
66
+ umbermark/core/signing.py,sha256=07Cc7f16PKhTbatznzexGTGOeNZhbJGap5QvcTllNTs,909
67
+ umbermark/core/ssh.py,sha256=K40zfyd26K-lZT8mi0E6hWSt-wQYwiLKZlO8z6TWYAE,4392
68
+ umbermark/core/ssh_fingerprint.py,sha256=s2eGduJBxbmpjD2tAkVWd1XQE9cgMfpT1zY36aE6d5c,632
69
+ umbermark/core/tiers.py,sha256=ZDpPrbLDC68K5CgO1SKZ5Ey7mqDCjsNbQJBeNX3OB1A,5345
70
+ umbermark/core/webhooks.py,sha256=AdkqeaCRMilJufZ5oxQCoaXKBMa0Di3s4S5TMOn8MGc,7792
71
+ umbermark/core/wording.py,sha256=Yw0eB2zsMWJBm81X11A35KxRnlpS7GgLiGB-5_sKhYc,13924
72
+ umbermark/core/external/__init__.py,sha256=NMyipAiYW4PpAfkonW6tGmesTBMRxnE8vmDNFv301Vw,288
73
+ umbermark/core/external/check.py,sha256=1Ql1FkgD3u4K8ALlPOboJexC5CIFLfsCrUBf1K-jC9U,1827
74
+ umbermark/core/external/engine.py,sha256=35AJVhM9VWnvlL9cWtjlpjJX8HiJVLAXZBlI3CPS86o,3226
75
+ umbermark/core/external/ownership.py,sha256=zpvz9QkSEUegzNJWA8LnSiNXUxdQicdz8fDZLbCAspE,20794
76
+ umbermark/core/external/probe.py,sha256=wkqGZg-AU3P8jJLyWNHgsbaKDde18A2OmdUap0YaQrY,3861
77
+ umbermark/core/external/socket_probe.py,sha256=pCt9bKguSsYDah6HjYq71NLIAhrgeMfD8TO63V1TeQ8,16157
78
+ umbermark/core/external/checks/__init__.py,sha256=sibKeKHcgUm8DeweGF0ROnap8zcxkzRwZucPkRWwzB0,1169
79
+ umbermark/core/external/checks/exposed_datastores.py,sha256=ssFUxGSJoCkTm9VwmtErPUq4DEhsnH4bbXSTrfCJ24U,2140
80
+ umbermark/core/external/checks/http_security_headers.py,sha256=vqLEQuNYr10WhdTzcCkmqjmJG96QSLHIEECL0mKILCk,10602
81
+ umbermark/core/external/checks/legacy_services.py,sha256=iAAxHEdDkY7NCrv3UViH83d1EOtKK65qV1ofLROxVxA,1898
82
+ umbermark/core/external/checks/open_management_ports.py,sha256=wpuJUGpytXiZxp-wsD2bORxgmp2SH3QduTsDZmaMXKQ,2681
83
+ umbermark/core/external/checks/ssh_alt_ports.py,sha256=PAdEOTrvhGFjUQwHUIf0yAU5Cef94KVORULhYxTXa5c,3600
84
+ umbermark/core/external/checks/ssh_weak_algorithms.py,sha256=9PKozJwfOvE-xrYcyajh6iyouEkN10EQOD6RKeefvUw,3603
85
+ umbermark/core/external/checks/tls_hygiene.py,sha256=sCkM0RH22fiWH6LLRYLe6Og-KatMcYQ3nVI_Et7vYLA,4989
86
+ umbermark/core/pdf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
87
+ umbermark/core/pdf/renderer.py,sha256=05XW10Yqh3Ndwyv7zfdrUBea-haNu7WY4p-zLlAXwXM,815
88
+ umbermark/core/pdf/weasyprint_renderer.py,sha256=tQbrSNOM-1Y-YNB6HCchA4dYkF4jTWXpCY-Sn6w2j88,616
89
+ umbermark_cli-1.0.0rc1.dist-info/METADATA,sha256=auk3QsD9expQZE4LE3FI0zm_5RbrwCXQewhO-IDgF64,331
90
+ umbermark_cli-1.0.0rc1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
91
+ umbermark_cli-1.0.0rc1.dist-info/entry_points.txt,sha256=QN7Tr9MH2fInhCNRYDOz9H5YFJwuGuo7WgpgEHMWZcY,48
92
+ umbermark_cli-1.0.0rc1.dist-info/top_level.txt,sha256=d3SP0jhXKY0P_B_52xzKfnpLWnbUhedUltMqcA8BFEA,10
93
+ umbermark_cli-1.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ umbermark = umbermark.cli:app
@@ -0,0 +1 @@
1
+ umbermark