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.
- umbermark/__init__.py +3 -0
- umbermark/checks/__init__.py +88 -0
- umbermark/checks/auto_updates_configured.py +149 -0
- umbermark/checks/dns_resolver_sanity.py +47 -0
- umbermark/checks/fail2ban_enabled.py +67 -0
- umbermark/checks/failed_login_anomaly.py +48 -0
- umbermark/checks/failed_systemd_units.py +41 -0
- umbermark/checks/firewall_protective.py +247 -0
- umbermark/checks/journald_persistence.py +55 -0
- umbermark/checks/kernel_age.py +74 -0
- umbermark/checks/listen_vs_firewall_delta.py +136 -0
- umbermark/checks/mac_framework_enforcing.py +97 -0
- umbermark/checks/pending_security_updates.py +188 -0
- umbermark/checks/public_listen_anomalies.py +200 -0
- umbermark/checks/reboot_pending.py +71 -0
- umbermark/checks/repo_gpg_trust.py +97 -0
- umbermark/checks/root_disk_free.py +46 -0
- umbermark/checks/ssh_non_default_port.py +62 -0
- umbermark/checks/ssh_password_auth.py +71 -0
- umbermark/checks/ssh_permit_root.py +59 -0
- umbermark/checks/ssh_weak_ciphers.py +85 -0
- umbermark/checks/sudo_requires_authentication.py +41 -0
- umbermark/checks/sudoers_nopasswd.py +111 -0
- umbermark/checks/swap_pressure.py +51 -0
- umbermark/checks/time_sync.py +55 -0
- umbermark/checks/tmp_hardening.py +45 -0
- umbermark/checks/user_password_aging.py +95 -0
- umbermark/checks/world_writable_suid_audit.py +171 -0
- umbermark/cli/__init__.py +4 -0
- umbermark/cli/app.py +23 -0
- umbermark/cli/auth.py +44 -0
- umbermark/cli/commands/__init__.py +0 -0
- umbermark/cli/commands/doctor.py +16 -0
- umbermark/cli/commands/login.py +80 -0
- umbermark/cli/commands/push.py +54 -0
- umbermark/cli/commands/report.py +26 -0
- umbermark/cli/commands/scan.py +77 -0
- umbermark/cli/commands/scan_local.py +72 -0
- umbermark/cli/commands/scan_wsl.py +35 -0
- umbermark/cli/commands/version.py +11 -0
- umbermark/cli/config.py +13 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/capture.py +228 -0
- umbermark/core/check.py +82 -0
- umbermark/core/checkin.py +89 -0
- umbermark/core/crypto.py +15 -0
- umbermark/core/duration.py +45 -0
- umbermark/core/engine.py +74 -0
- umbermark/core/external/__init__.py +6 -0
- umbermark/core/external/check.py +54 -0
- umbermark/core/external/checks/__init__.py +31 -0
- umbermark/core/external/checks/exposed_datastores.py +55 -0
- umbermark/core/external/checks/http_security_headers.py +249 -0
- umbermark/core/external/checks/legacy_services.py +52 -0
- umbermark/core/external/checks/open_management_ports.py +65 -0
- umbermark/core/external/checks/ssh_alt_ports.py +77 -0
- umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
- umbermark/core/external/checks/tls_hygiene.py +107 -0
- umbermark/core/external/engine.py +89 -0
- umbermark/core/external/ownership.py +607 -0
- umbermark/core/external/probe.py +103 -0
- umbermark/core/external/socket_probe.py +400 -0
- umbermark/core/firewall.py +713 -0
- umbermark/core/fleet.py +92 -0
- umbermark/core/history.py +235 -0
- umbermark/core/html_report.py +306 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +44 -0
- umbermark/core/partial.py +57 -0
- umbermark/core/pdf/__init__.py +0 -0
- umbermark/core/pdf/renderer.py +26 -0
- umbermark/core/pdf/weasyprint_renderer.py +18 -0
- umbermark/core/platform.py +73 -0
- umbermark/core/redact.py +75 -0
- umbermark/core/report.py +25 -0
- umbermark/core/scoring.py +105 -0
- umbermark/core/settings.py +238 -0
- umbermark/core/short_id.py +27 -0
- umbermark/core/signing.py +24 -0
- umbermark/core/ssh.py +156 -0
- umbermark/core/ssh_fingerprint.py +16 -0
- umbermark/core/tiers.py +128 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
- umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|