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,55 @@
1
+ """External check: data stores reachable from the internet.
2
+
3
+ A database/cache directly exposed to the public internet is one of the highest-
4
+ signal outside-in findings — it routinely leads to data theft or ransom. We only
5
+ test whether the port answers; we never authenticate or read data.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from umbermark.core.check import Finding, Severity, Verdict
10
+ from umbermark.core.external.check import ExternalCheck
11
+ from umbermark.core.external.probe import ExternalProbe
12
+
13
+ # Common data-store ports that should never be open to the internet.
14
+ _DATASTORES = {
15
+ 6379: "Redis",
16
+ 5432: "PostgreSQL",
17
+ 3306: "MySQL/MariaDB",
18
+ 27017: "MongoDB",
19
+ 9200: "Elasticsearch",
20
+ 11211: "Memcached",
21
+ 9000: "ClickHouse",
22
+ 5984: "CouchDB",
23
+ }
24
+
25
+
26
+ class ExposedDataStoresCheck(ExternalCheck):
27
+ active = True # port probing: ownership-verified targets only (UMBR-26)
28
+ id = "external_exposed_datastores"
29
+ title = "Data stores not exposed to the internet"
30
+ category = "external_network"
31
+ severity = Severity.CRITICAL
32
+ weight = 25
33
+ remediation = (
34
+ "Bind data stores to localhost or a private network and block their "
35
+ "ports at the host/edge firewall. Never expose Redis, PostgreSQL, MySQL, "
36
+ "MongoDB, Elasticsearch, or Memcached directly to the internet; put them "
37
+ "behind your application, a VPN, or an SSH tunnel."
38
+ )
39
+
40
+ def run(self, probe: ExternalProbe) -> Finding:
41
+ exposed: list[tuple[int, str]] = []
42
+ for port, name in _DATASTORES.items():
43
+ try:
44
+ if probe.tcp_connect(port):
45
+ exposed.append((port, name))
46
+ except Exception:
47
+ continue # a probe error for one port shouldn't sink the check
48
+ if exposed:
49
+ listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
50
+ return self._finding(
51
+ Verdict.FAIL, evidence=f"reachable from the internet: {listing}"
52
+ )
53
+ return self._finding(
54
+ Verdict.PASS, evidence="no common data-store ports reachable from the internet"
55
+ )
@@ -0,0 +1,249 @@
1
+ """External check: HTTP security response headers.
2
+
3
+ A single unauthenticated request to the site's HTTPS endpoint. Missing hardening
4
+ headers don't breach anything on their own — they remove defenses against
5
+ downgrade, clickjacking, and MIME-sniffing — so this is advisory (WARN, medium).
6
+
7
+ A header that is present but set to a useless value (HSTS max-age=0, a CSP that
8
+ allows any script, a bogus X-Frame-Options) gives no protection, so it is
9
+ reported the same way as a missing one.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from umbermark.core.check import Finding, Severity, Verdict
16
+ from umbermark.core.external.check import ExternalCheck
17
+ from umbermark.core.external.probe import ExternalProbe
18
+
19
+ # Header -> what it defends against, in report-friendly order.
20
+ _EXPECTED = {
21
+ "strict-transport-security": "forces HTTPS (HSTS)",
22
+ "content-security-policy": "limits script/content sources (CSP)",
23
+ "x-frame-options": "blocks clickjacking",
24
+ "x-content-type-options": "stops MIME-sniffing",
25
+ }
26
+
27
+ # HSTS shorter than 6 months (Mozilla Observatory's floor, 15768000 s) is only a
28
+ # note, not a WARN: Umbermark's own fix starts at max-age=300 on purpose, so a
29
+ # mistake fixes itself, and tells the user to raise it once the site is stable.
30
+ _MIN_HSTS_MAX_AGE = 15768000
31
+ # Anchored to a directive start so "xmax-age=..." is not read as max-age.
32
+ _MAX_AGE_RE = re.compile(r"(?:^|;)\s*max-age\s*=\s*\"?(\d+)\"?(?=\s*(?:;|,|$))",
33
+ re.IGNORECASE,
34
+ )
35
+ # Host-source with a bare "*" host: "*", "https://*", "http://*:443", "*:443".
36
+ # Also a wildcard TLD ("https://*.com"): "*." plus a single label allows a whole TLD.
37
+ _ANY_HOST_RE = re.compile(
38
+ r"^(?:[a-z][a-z0-9+.-]*:)?(?://)?\*(?:\.[a-z0-9-]+)?(?::|/|$)"
39
+ )
40
+ _ANY_SCHEME = {"http:", "https:", "data:", "blob:", "filesystem:"}
41
+ _INTEGRITY_RE = re.compile(r"'(?:nonce|sha256|sha384|sha512)-[A-Za-z0-9+/_=-]+'")
42
+ _XFO_OK = {"deny", "sameorigin"}
43
+
44
+
45
+ def _first_header(value: str) -> str:
46
+ """First of several repeated headers (joined with ',').
47
+
48
+ RFC 6797 s8.1: a UA processes only the first Strict-Transport-Security
49
+ header and ignores the rest, so only that one is judged.
50
+ """
51
+ return value.split(",")[0]
52
+
53
+
54
+ def _weak_hsts(value: str) -> str | None:
55
+ found = _MAX_AGE_RE.findall(_first_header(value))
56
+ if not found:
57
+ return "no valid max-age"
58
+ if len(found) > 1:
59
+ # RFC 6797 s6.1: each directive once; browsers ignore a non-conforming header.
60
+ return "max-age is repeated, so browsers ignore the header"
61
+ if int(found[0]) == 0:
62
+ return "max-age=0 switches HSTS off"
63
+ return None
64
+
65
+
66
+ def _hsts_note(value: str) -> str | None:
67
+ """Advisory (not WARN) note for a sound but short HSTS max-age."""
68
+ found = _MAX_AGE_RE.findall(_first_header(value))
69
+ if len(found) == 1 and 0 < int(found[0]) < _MIN_HSTS_MAX_AGE:
70
+ return (f"note: Strict-Transport-Security max-age={int(found[0])} is short; "
71
+ "raise to >=6 months (15768000) when stable")
72
+ return None
73
+
74
+
75
+ _UNRESOLVED = "no script-src/default-src"
76
+
77
+
78
+ def _csp_policy(policy: str) -> tuple[bool, str | None]:
79
+ """Judge one CSP policy. Returns (has_script_policy, weakness)."""
80
+ directives: dict[str, list[str]] = {}
81
+ for part in policy.split(";"):
82
+ tokens = part.strip().split()
83
+ if tokens:
84
+ # First occurrence of a directive wins, per the CSP spec.
85
+ directives.setdefault(tokens[0].lower(), [t.lower() for t in tokens[1:]])
86
+ # CSP3 fallback chains differ per use, so judge each separately:
87
+ # <script> elements : script-src-elem -> script-src -> default-src
88
+ # inline handlers : script-src-attr -> script-src -> default-src
89
+ # eval()/string code: script-src -> default-src (never the -elem/-attr forms)
90
+ def pick(*names: str) -> list[str] | None:
91
+ for n in names:
92
+ if n in directives:
93
+ return directives[n]
94
+ return None
95
+
96
+ elem = pick("script-src-elem", "script-src", "default-src")
97
+ attr = pick("script-src-attr", "script-src", "default-src")
98
+ ev = pick("script-src", "default-src")
99
+ if elem is None and attr is None and ev is None:
100
+ # No script policy to judge (e.g. frame-ancestors only); _weak_csp decides.
101
+ return False, None
102
+ # Explicit weaknesses first: they count even when another chain is unresolved.
103
+ if ev is not None and "'unsafe-eval'" in ev:
104
+ return True, "script sources allow 'unsafe-eval'"
105
+ # CSP3: with 'strict-dynamic', browsers ignore host/scheme sources and
106
+ # 'unsafe-inline' (kept only as a fallback for old browsers). This is the
107
+ # recommended strict policy, so it is exempt, per list.
108
+ def inline_ok(sources: list[str]) -> bool:
109
+ # A nonce or hash makes browsers ignore 'unsafe-inline'.
110
+ # (a valid, non-empty base64 value is required; browsers drop invalid ones).
111
+ hardened = any(_INTEGRITY_RE.fullmatch(t) for t in sources)
112
+ return "'unsafe-inline'" not in sources or hardened
113
+
114
+ if elem is not None and "'strict-dynamic'" not in elem:
115
+ if any(t in _ANY_SCHEME or _ANY_HOST_RE.match(t) for t in elem):
116
+ return True, "script sources allow any origin (wildcard or bare scheme)"
117
+ if not inline_ok(elem):
118
+ return True, "script sources allow 'unsafe-inline'"
119
+ # Origins are meaningless for inline handlers; only 'unsafe-inline' matters.
120
+ if attr is not None and "'strict-dynamic'" not in attr and not inline_ok(attr):
121
+ return True, "script sources allow 'unsafe-inline' (inline event handlers)"
122
+ # A chain that resolves to nothing means that use has no restriction at all.
123
+ if elem is None:
124
+ return True, _UNRESOLVED + ": <script> elements are unrestricted"
125
+ if attr is None:
126
+ return True, _UNRESOLVED + ": inline event handlers are unrestricted"
127
+ if ev is None:
128
+ return True, _UNRESOLVED + ": eval() is unrestricted"
129
+ return True, None
130
+
131
+
132
+ def _frame_ancestors(policy: str) -> list[str] | None:
133
+ """Sources of the first frame-ancestors directive in one policy, if any."""
134
+ for part in policy.split(";"):
135
+ toks = part.strip().lower().split()
136
+ if toks and toks[0] == "frame-ancestors":
137
+ return toks[1:]
138
+ return None
139
+
140
+
141
+ def _weak_csp(value: str) -> str | None:
142
+ """Flag a CSP whose script controls let any / inline script run, or whose
143
+ frame-ancestors lets any site frame the page.
144
+
145
+ Several policies (repeated headers, folded with ',') are enforced together,
146
+ so the result is as strong as the strongest of them, in any order.
147
+ """
148
+ policies = value.split(",")
149
+ # Browsers enforce every policy: framing is allowed only if all of them allow
150
+ # it, so one restrictive frame-ancestors is enough (order does not matter).
151
+ fa = [f for f in (_frame_ancestors(p) for p in policies) if f is not None]
152
+ fa_restricts = any(
153
+ not any(t in _ANY_SCHEME or _ANY_HOST_RE.match(t) for t in srcs) for srcs in fa
154
+ )
155
+ if fa and not fa_restricts:
156
+ return "frame-ancestors allows any site to frame the page"
157
+
158
+ reasons: list[str] = []
159
+ for policy in policies:
160
+ judged, reason = _csp_policy(policy)
161
+ if judged and reason is None:
162
+ return None
163
+ if reason:
164
+ reasons.append(reason)
165
+ definite = [r for r in reasons if not r.startswith(_UNRESOLVED)]
166
+ if definite:
167
+ return definite[0]
168
+ if fa_restricts:
169
+ # A restrictive frame-ancestors is the deliberately conservative baseline
170
+ # Umbermark's own fix ships. It stays accepted when the policy only adds
171
+ # partial script directives: those leave nothing weaker than the baseline.
172
+ return None
173
+ if reasons:
174
+ return reasons[0]
175
+ return "no script-src, default-src or frame-ancestors: the policy restricts nothing"
176
+
177
+
178
+ def _tokens(value: str) -> set[str]:
179
+ """Distinct lowercased comma-separated tokens (proxy + app may both set it)."""
180
+ return {t.strip().lower() for t in value.split(",") if t.strip()}
181
+
182
+
183
+ def _weak_xfo(value: str) -> str | None:
184
+ toks = _tokens(value)
185
+ # Browsers accept a repeated identical value; conflicting/bad ones are weak.
186
+ if len(toks) != 1 or not toks <= _XFO_OK:
187
+ return f"value {value.strip()!r} is not DENY or SAMEORIGIN"
188
+ return None
189
+
190
+
191
+ def _weak_xcto(value: str) -> str | None:
192
+ if _tokens(value) != {"nosniff"}:
193
+ return f"value {value.strip()!r} is not nosniff"
194
+ return None
195
+
196
+
197
+ _VALUE_CHECKS = {
198
+ "strict-transport-security": _weak_hsts,
199
+ "content-security-policy": _weak_csp,
200
+ "x-frame-options": _weak_xfo,
201
+ "x-content-type-options": _weak_xcto,
202
+ }
203
+
204
+
205
+ class HttpSecurityHeadersCheck(ExternalCheck):
206
+ id = "external_http_security_headers"
207
+ title = "HTTP responses set the core security headers"
208
+ category = "external_web"
209
+ severity = Severity.MEDIUM
210
+ weight = 6
211
+ remediation = (
212
+ "Set the standard hardening headers on HTTPS responses: "
213
+ "Strict-Transport-Security (HSTS, max-age of at least 6 months), Content-Security-Policy, "
214
+ "X-Frame-Options (or CSP frame-ancestors), and X-Content-Type-Options: "
215
+ "nosniff. Add them at the web server or reverse proxy."
216
+ )
217
+
218
+ def run(self, probe: ExternalProbe) -> Finding:
219
+ headers = probe.http_headers(443, tls=True)
220
+ if headers is None:
221
+ return self._finding(
222
+ Verdict.SKIP,
223
+ skip_reason="no HTTPS service answered on port 443",
224
+ )
225
+
226
+ lowered = {k.lower(): str(v) for k, v in headers.items()}
227
+ missing = [name for name in _EXPECTED if name not in lowered]
228
+ weak = [
229
+ f"{name} ({reason})"
230
+ for name, judge in _VALUE_CHECKS.items()
231
+ if name in lowered and (reason := judge(lowered[name]))
232
+ ]
233
+ if missing or weak:
234
+ parts = []
235
+ if missing:
236
+ listing = ", ".join(f"{n} ({_EXPECTED[n]})" for n in missing)
237
+ parts.append(f"missing security headers: {listing}")
238
+ if weak:
239
+ parts.append("weak header values: " + "; ".join(weak))
240
+ hsts = lowered.get("strict-transport-security")
241
+ note = _hsts_note(hsts) if hsts is not None else None
242
+ if note:
243
+ parts.append(note)
244
+ return self._finding(Verdict.WARN, evidence=" | ".join(parts))
245
+ evidence = "all core security headers present with sound values"
246
+ note = _hsts_note(lowered["strict-transport-security"])
247
+ if note:
248
+ evidence += f" ({note})"
249
+ return self._finding(Verdict.PASS, evidence=evidence)
@@ -0,0 +1,52 @@
1
+ """External check: legacy plaintext services exposed to the internet.
2
+
3
+ Telnet, FTP, and the BSD r-services transmit credentials and data in the clear
4
+ and have no place facing the public internet. We only test whether the port
5
+ answers; we never authenticate.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from umbermark.core.check import Finding, Severity, Verdict
10
+ from umbermark.core.external.check import ExternalCheck
11
+ from umbermark.core.external.probe import ExternalProbe
12
+
13
+ # Plaintext / legacy services that should never be internet-facing.
14
+ _LEGACY = {
15
+ 23: "Telnet",
16
+ 21: "FTP",
17
+ 69: "TFTP",
18
+ 512: "rexec",
19
+ 513: "rlogin",
20
+ 514: "rsh",
21
+ }
22
+
23
+
24
+ class LegacyPlaintextServicesCheck(ExternalCheck):
25
+ active = True # port probing: ownership-verified targets only (UMBR-26)
26
+ id = "external_legacy_plaintext_services"
27
+ title = "No legacy plaintext services exposed (telnet, FTP, r-services)"
28
+ category = "external_network"
29
+ severity = Severity.HIGH
30
+ weight = 12
31
+ remediation = (
32
+ "Disable telnet, FTP, and the BSD r-services on the public interface. "
33
+ "Use SSH/SFTP instead, and block ports 21/23/69/512-514 at the firewall. "
34
+ "These protocols send credentials and data in cleartext."
35
+ )
36
+
37
+ def run(self, probe: ExternalProbe) -> Finding:
38
+ exposed: list[tuple[int, str]] = []
39
+ for port, name in _LEGACY.items():
40
+ try:
41
+ if probe.tcp_connect(port):
42
+ exposed.append((port, name))
43
+ except Exception:
44
+ continue
45
+ if exposed:
46
+ listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
47
+ return self._finding(
48
+ Verdict.FAIL, evidence=f"plaintext service reachable: {listing}"
49
+ )
50
+ return self._finding(
51
+ Verdict.PASS, evidence="no legacy plaintext services reachable"
52
+ )
@@ -0,0 +1,65 @@
1
+ """External check: remote-management interfaces exposed to the internet.
2
+
3
+ The attack-surface inventory. Remote desktop, container/orchestrator control
4
+ planes, and cluster databases should sit behind a VPN or firewall — not answer
5
+ the public internet. Unlike the data-store (critical) and plaintext-legacy (high)
6
+ checks, an open management port is expanded surface rather than a guaranteed
7
+ breach, so this is advisory (WARN). We only test whether the port answers; we
8
+ never authenticate.
9
+
10
+ SSH (22) and the conventional web ports (80/443) are the expected baseline and
11
+ are deliberately NOT flagged — open SSH is normal, and SSH crypto hygiene is
12
+ covered separately.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from umbermark.core.check import Finding, Severity, Verdict
17
+ from umbermark.core.external.check import ExternalCheck
18
+ from umbermark.core.external.probe import ExternalProbe
19
+
20
+ # Remote-management / control-plane ports that should not face the public
21
+ # internet. Kept distinct from data stores (port-disjoint) and plaintext legacy.
22
+ _MGMT = {
23
+ 3389: "RDP",
24
+ 5900: "VNC",
25
+ 5901: "VNC",
26
+ 2375: "Docker API (plaintext)",
27
+ 2376: "Docker API (TLS)",
28
+ 6443: "Kubernetes API",
29
+ 10250: "Kubelet API",
30
+ 2379: "etcd",
31
+ }
32
+
33
+
34
+ class OpenManagementPortsCheck(ExternalCheck):
35
+ active = True # port probing: ownership-verified targets only (UMBR-26)
36
+ id = "external_open_management_ports"
37
+ title = "Remote-management interfaces not exposed to the internet"
38
+ category = "external_network"
39
+ severity = Severity.MEDIUM
40
+ weight = 6
41
+ remediation = (
42
+ "Restrict remote-management interfaces (RDP, VNC, the Docker/Kubernetes "
43
+ "control plane, etcd) to a VPN or a trusted IP allow-list at the firewall. "
44
+ "These should never be reachable from the open internet — expose only the "
45
+ "ports your service actually needs (typically 80/443, plus SSH)."
46
+ )
47
+
48
+ def run(self, probe: ExternalProbe) -> Finding:
49
+ exposed: list[tuple[int, str]] = []
50
+ for port, name in _MGMT.items():
51
+ try:
52
+ if probe.tcp_connect(port):
53
+ exposed.append((port, name))
54
+ except Exception:
55
+ continue # one port's probe error shouldn't sink the check
56
+ if exposed:
57
+ listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
58
+ return self._finding(
59
+ Verdict.WARN,
60
+ evidence=f"management interface reachable from the internet: {listing}",
61
+ )
62
+ return self._finding(
63
+ Verdict.PASS,
64
+ evidence="no remote-management ports reachable from the internet",
65
+ )
@@ -0,0 +1,77 @@
1
+ """External check: weak SSH algorithms on alternate SSH ports (UMBR-38).
2
+
3
+ ``external_ssh_weak_algorithms`` only reads port 22. An external scanner has no
4
+ shell on the host, so it cannot read sshd's ``Port`` directives or an
5
+ ``ssh.socket`` override. Instead this ACTIVE check probes a short list of the
6
+ ports administrators commonly move SSH to and runs the same weak-algorithm
7
+ evaluation on every one that answers with an SSH banner. The finding names the
8
+ port. Port probing is gated behind verified ownership (UMBR-26): for an
9
+ unverified target the engine skips this check with an explanatory reason.
10
+
11
+ The on-box ``ssh_weak_ciphers`` check (``sshd -T``) already covers every
12
+ configured port; this is the outside-in complement for hosts scanned without
13
+ credentials.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from umbermark.core.check import Finding, Severity, Verdict
18
+ from umbermark.core.external.check import ExternalCheck
19
+ from umbermark.core.external.checks.ssh_weak_algorithms import find_weak_algorithms
20
+ from umbermark.core.external.engine import ALT_SSH_UNVERIFIED_SKIP_REASON
21
+ from umbermark.core.external.probe import ExternalProbe
22
+
23
+ # Common non-default SSH ports (22 itself is covered by the passive check).
24
+ _ALT_SSH_PORTS = (2222, 22222, 2022, 822, 2200, 22022, 2322)
25
+
26
+
27
+ class SshAltPortsCheck(ExternalCheck):
28
+ active = True # port probing: ownership-verified targets only (UMBR-26)
29
+ unverified_skip_reason = ALT_SSH_UNVERIFIED_SKIP_REASON
30
+ id = "external_ssh_alt_ports"
31
+ title = "SSH on alternate ports offers no weak or deprecated algorithms"
32
+ category = "external_network"
33
+ severity = Severity.HIGH
34
+ weight = 12
35
+ remediation = (
36
+ "Apply the same algorithm restrictions to every SSH port. KexAlgorithms, Ciphers, MACs "
37
+ "and HostKeyAlgorithms in sshd_config apply to all ports sshd listens on: pin them to a "
38
+ "modern list (e.g. curve25519-sha256, chacha20-poly1305@openssh.com / aes256-gcm, "
39
+ "hmac-sha2-512-etm, ssh-ed25519 / rsa-sha2-512). If an extra SSH port is not needed, "
40
+ "remove its Port line (and any ssh.socket override) and close it at the firewall."
41
+ )
42
+
43
+ def run(self, probe: ExternalProbe) -> Finding:
44
+ found: list[int] = [] # ports that answered with an SSH banner
45
+ weak_by_port: dict[int, list[tuple[str, str]]] = {}
46
+ for port in _ALT_SSH_PORTS:
47
+ try:
48
+ algos = probe.ssh_algorithms(port)
49
+ except Exception:
50
+ continue # one port's probe error shouldn't sink the check
51
+ if algos is None:
52
+ continue
53
+ found.append(port)
54
+ weak = find_weak_algorithms(algos)
55
+ if weak:
56
+ weak_by_port[port] = weak
57
+ if weak_by_port:
58
+ parts = [
59
+ f"port {port}: " + ", ".join(f"{n} ({cat})" for cat, n in weak)
60
+ for port, weak in sorted(weak_by_port.items())
61
+ ]
62
+ return self._finding(
63
+ Verdict.FAIL,
64
+ evidence="weak/deprecated SSH algorithms offered on alternate ports: "
65
+ + "; ".join(parts),
66
+ )
67
+ if found:
68
+ ports = ", ".join(str(p) for p in sorted(found))
69
+ return self._finding(
70
+ Verdict.PASS,
71
+ evidence=f"SSH on alternate port(s) {ports} offers only modern algorithms",
72
+ )
73
+ tried = ", ".join(str(p) for p in _ALT_SSH_PORTS)
74
+ return self._finding(
75
+ Verdict.PASS,
76
+ evidence=f"no SSH service answered on the alternate ports checked ({tried})",
77
+ )
@@ -0,0 +1,93 @@
1
+ """External check: weak/deprecated algorithms offered in the SSH handshake.
2
+
3
+ Read entirely from the pre-auth KEXINIT — the public list of algorithms the
4
+ server is willing to negotiate, the same thing ``nmap --script ssh2-enum-algos``
5
+ sees. No login, no credentials. Offering a broken algorithm (SHA-1 key
6
+ exchange, CBC ciphers, MD5/SHA-1 MACs, DSA/ssh-rsa host keys) leaves the
7
+ connection open to downgrade even when stronger options are also advertised.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from umbermark.core.check import Finding, Severity, Verdict
12
+ from umbermark.core.external.check import ExternalCheck
13
+ from umbermark.core.external.probe import ExternalProbe, SshAlgorithms
14
+
15
+
16
+ def _weak_kex(name: str) -> bool:
17
+ # SHA-1 key exchange (incl. the 1024-bit group1) is deprecated.
18
+ return "sha1" in name.lower()
19
+
20
+
21
+ def _weak_cipher(name: str) -> bool:
22
+ n = name.lower()
23
+ return (
24
+ n.endswith("-cbc")
25
+ or n.startswith(("3des", "arcfour", "blowfish", "cast128"))
26
+ or "rc4" in n
27
+ or n == "none"
28
+ )
29
+
30
+
31
+ def _weak_mac(name: str) -> bool:
32
+ n = name.lower()
33
+ return "md5" in n or "sha1" in n or n.startswith("umac-64") or n == "none"
34
+
35
+
36
+ def _weak_host_key(name: str) -> bool:
37
+ # ssh-dss (DSA) is broken; ssh-rsa is the SHA-1 RSA signature OpenSSH
38
+ # disabled by default in 8.8. rsa-sha2-256/512 are the modern replacements.
39
+ return name.lower() in {"ssh-dss", "ssh-rsa"}
40
+
41
+
42
+ def find_weak_algorithms(algos: SshAlgorithms) -> list[tuple[str, str]]:
43
+ """Every (category, name) pair in a KEXINIT offer that is weak/deprecated.
44
+
45
+ Shared by the port-22 check and the alternate-port check (UMBR-38) so both
46
+ apply exactly the same definition of "weak".
47
+ """
48
+ weak: list[tuple[str, str]] = []
49
+ weak += [("KEX", n) for n in algos.kex if _weak_kex(n)]
50
+ weak += [("cipher", n) for n in algos.ciphers if _weak_cipher(n)]
51
+ weak += [("MAC", n) for n in algos.macs if _weak_mac(n)]
52
+ weak += [("host-key", n) for n in algos.host_keys if _weak_host_key(n)]
53
+ return weak
54
+
55
+
56
+ class SshWeakAlgorithmsCheck(ExternalCheck):
57
+ id = "external_ssh_weak_algorithms"
58
+ title = "SSH offers no weak or deprecated algorithms"
59
+ category = "external_network"
60
+ severity = Severity.HIGH
61
+ weight = 12
62
+ remediation = (
63
+ "Pin sshd to modern algorithms only. In sshd_config set KexAlgorithms, "
64
+ "Ciphers, MACs, and HostKeyAlgorithms to a curated modern list "
65
+ "(e.g. curve25519-sha256, chacha20-poly1305@openssh.com / aes256-gcm, "
66
+ "hmac-sha2-512-etm, ssh-ed25519 / rsa-sha2-512). Remove SHA-1 key "
67
+ "exchange, CBC ciphers, MD5/SHA-1 MACs, and the ssh-dss/ssh-rsa host keys."
68
+ )
69
+
70
+ def run(self, probe: ExternalProbe) -> Finding:
71
+ algos = probe.ssh_algorithms(22)
72
+ if algos is None:
73
+ return self._finding(
74
+ Verdict.SKIP,
75
+ skip_reason=(
76
+ "SSH KEXINIT not readable on port 22 "
77
+ "(host unreachable, filtered, or SSH on a non-standard port)"
78
+ ),
79
+ )
80
+ weak = find_weak_algorithms(algos)
81
+ if weak:
82
+ listing = ", ".join(f"{n} ({cat})" for cat, n in weak)
83
+ return self._finding(
84
+ Verdict.FAIL,
85
+ evidence=f"weak/deprecated SSH algorithms offered: {listing}",
86
+ )
87
+ return self._finding(
88
+ Verdict.PASS,
89
+ evidence=(
90
+ "SSH offers only modern algorithms (no SHA-1 key exchange, CBC "
91
+ "ciphers, MD5/SHA-1 MACs, or DSA/ssh-rsa host keys)"
92
+ ),
93
+ )