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,188 @@
|
|
|
1
|
+
"""Pending security updates — apt-check on Ubuntu, apt-get -s fallback on Debian."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
7
|
+
from umbermark.core.ssh import CommandRunner
|
|
8
|
+
|
|
9
|
+
# First token of a `dnf check-update` row: "<name>.<arch>". Continuation and header lines may
|
|
10
|
+
# also match; that only adds keys no advisory package will ever hit.
|
|
11
|
+
_NAME_ARCH = re.compile(r"^(\S+)\.([A-Za-z0-9_]+)$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _upgradable(check_update) -> set[tuple[str, str]] | None:
|
|
15
|
+
"""(name, arch) pairs `dnf check-update` would upgrade, or None if it gave no usable answer.
|
|
16
|
+
|
|
17
|
+
check-update compares against the NEWEST installed version of each package, so it is the
|
|
18
|
+
authority on what is still pending. Exit 0 means nothing to upgrade (its stdout can still
|
|
19
|
+
carry "Security: ..." notices); exit 100 means the rows are upgrades."""
|
|
20
|
+
if check_update.exit_code == 0:
|
|
21
|
+
return set()
|
|
22
|
+
if check_update.exit_code != 100:
|
|
23
|
+
return None
|
|
24
|
+
out: set[tuple[str, str]] = set()
|
|
25
|
+
for ln in check_update.stdout.splitlines():
|
|
26
|
+
parts = ln.split()
|
|
27
|
+
m = _NAME_ARCH.match(parts[0]) if parts else None
|
|
28
|
+
if m:
|
|
29
|
+
out.add((m.group(1), m.group(2)))
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_security_row(line: str) -> bool:
|
|
34
|
+
"""dnf4 rows are (ID, "<Severity>/Sec.", package). dnf5 rows have FIVE columns, in this order:
|
|
35
|
+
Name, Type, Severity, Package, Issued (libdnf5 advisorylist.cpp; dnf5-advisory(8)). Type is
|
|
36
|
+
"security" (bugfix/enhancement/... are other types); Issued is a date plus time, so the LAST token
|
|
37
|
+
is NOT the package: use _row_nevra() to find it."""
|
|
38
|
+
if not line.strip():
|
|
39
|
+
return False
|
|
40
|
+
if "Sec." in line:
|
|
41
|
+
return True
|
|
42
|
+
parts = line.split()
|
|
43
|
+
return len(parts) >= 3 and parts[1].lower() == "security"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _row_nevra(line: str) -> str:
|
|
47
|
+
"""Package NEVRA of an updateinfo row. dnf4: last column. dnf5 (Name, Type, Severity, Package,
|
|
48
|
+
Issued): the fourth column, because the last token is the Issued time."""
|
|
49
|
+
parts = line.split()
|
|
50
|
+
if len(parts) >= 3 and parts[1].lower() == "security":
|
|
51
|
+
# An empty Severity cell collapses under split(), shifting Package to index 2: take the
|
|
52
|
+
# first token after Type that parses as a NEVRA (not the fixed index 3). Package names may
|
|
53
|
+
# start with a digit (7zip, 389-ds-base); date/time/severity tokens have no "." so never parse.
|
|
54
|
+
for tok in parts[2:]:
|
|
55
|
+
if _advisory_name_arch(tok) is not None and "-" in tok:
|
|
56
|
+
return tok
|
|
57
|
+
return parts[3] if len(parts) > 3 else parts[-1]
|
|
58
|
+
return parts[-1]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _advisory_name_arch(nevra: str) -> tuple[str, str] | None:
|
|
62
|
+
""""kernel-core-5.14.0-687.58.1.el9_8.x86_64" -> ("kernel-core", "x86_64"); epoch-safe."""
|
|
63
|
+
if "." not in nevra:
|
|
64
|
+
return None
|
|
65
|
+
nevr, arch = nevra.rsplit(".", 1)
|
|
66
|
+
return nevr.rsplit("-", 2)[0], arch
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PendingSecurityUpdatesCheck(Check):
|
|
70
|
+
id = "pending_security_updates"
|
|
71
|
+
title = "Pending security updates"
|
|
72
|
+
category = "patching"
|
|
73
|
+
severity = Severity.HIGH
|
|
74
|
+
weight = 14
|
|
75
|
+
applicable_distros = ("debian", "rhel")
|
|
76
|
+
remediation = (
|
|
77
|
+
"Install pending security updates:\n"
|
|
78
|
+
" sudo apt update && sudo unattended-upgrade -d\n"
|
|
79
|
+
"Or interactively:\n"
|
|
80
|
+
" sudo apt update && sudo apt upgrade\n"
|
|
81
|
+
"Consider enabling unattended-upgrades for automated security patching."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _verdict_for_counts(self, total: int, security: int) -> Finding:
|
|
85
|
+
evidence = f"{security} security updates pending (of {total} total)"
|
|
86
|
+
if security == 0:
|
|
87
|
+
return self._finding(Verdict.PASS, evidence=evidence)
|
|
88
|
+
if security <= 5:
|
|
89
|
+
return self._finding(Verdict.WARN, evidence=evidence)
|
|
90
|
+
return self._finding(Verdict.FAIL, evidence=evidence + " — install promptly")
|
|
91
|
+
|
|
92
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
93
|
+
# Issued up front, not inside the RHEL branch: the capture enumerator runs checks against an
|
|
94
|
+
# all-failing runner, so a gated command is never captured and uploads get a wrong total (UMBR-23).
|
|
95
|
+
r4_total = runner.run("dnf -q check-update 2>/dev/null")
|
|
96
|
+
# UMBR-42: dnf5 (Fedora 41+) treats the bare word "security" as a package spec, so the
|
|
97
|
+
# positional form below prints nothing at all (a false PASS). `--security` works on dnf4 and dnf5.
|
|
98
|
+
r4_flag = runner.run("dnf -q updateinfo list --security 2>/dev/null")
|
|
99
|
+
# 1) Ubuntu-style: update-notifier-common ships apt-check.
|
|
100
|
+
r = runner.run("/usr/lib/update-notifier/apt-check 2>&1")
|
|
101
|
+
if r.ok and ";" in r.stdout:
|
|
102
|
+
parts = r.stdout.strip().split(";", 1)
|
|
103
|
+
try:
|
|
104
|
+
return self._verdict_for_counts(int(parts[0]), int(parts[1]))
|
|
105
|
+
except (ValueError, IndexError):
|
|
106
|
+
pass # fall through to generic fallback
|
|
107
|
+
|
|
108
|
+
# 2) Generic Debian/Ubuntu fallback: simulate an upgrade and count
|
|
109
|
+
# "Inst" lines whose archive parenthetical mentions "security".
|
|
110
|
+
r2 = runner.run(
|
|
111
|
+
"apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null"
|
|
112
|
+
)
|
|
113
|
+
if r2.ok and r2.stdout:
|
|
114
|
+
inst_lines = [
|
|
115
|
+
ln for ln in r2.stdout.splitlines() if ln.startswith("Inst")
|
|
116
|
+
]
|
|
117
|
+
if inst_lines or "0 upgraded" in r2.stdout:
|
|
118
|
+
total = len(inst_lines)
|
|
119
|
+
security = sum(
|
|
120
|
+
1 for ln in inst_lines if re.search(r"[Ss]ecurity", ln)
|
|
121
|
+
)
|
|
122
|
+
return self._verdict_for_counts(total, security)
|
|
123
|
+
|
|
124
|
+
# 3) Tertiary fallback: `apt list --upgradable` is robust against
|
|
125
|
+
# broken package state (which is a real production scenario, not
|
|
126
|
+
# a hypothetical — surfaced by the WSL dogfood). Format example:
|
|
127
|
+
# libnghttp2-14/stable-security 1.64.0-1.1+deb13u1 amd64 [upgradable from: ...]
|
|
128
|
+
r3 = runner.run("apt list --upgradable 2>/dev/null")
|
|
129
|
+
if r3.ok and r3.stdout and "Listing" in r3.stdout:
|
|
130
|
+
pkg_lines = [
|
|
131
|
+
ln for ln in r3.stdout.splitlines()
|
|
132
|
+
if "[upgradable from:" in ln
|
|
133
|
+
]
|
|
134
|
+
total = len(pkg_lines)
|
|
135
|
+
security = sum(
|
|
136
|
+
1 for ln in pkg_lines
|
|
137
|
+
if re.match(r"^\S+/\S*[Ss]ecurity\b", ln)
|
|
138
|
+
)
|
|
139
|
+
return self._verdict_for_counts(total, security)
|
|
140
|
+
|
|
141
|
+
# 4) RHEL/Rocky/Alma: dnf updateinfo for security count, dnf check-update for total.
|
|
142
|
+
# `dnf check-update` exits 100 when updates are available, 0 when none.
|
|
143
|
+
r4_sec = runner.run("dnf -q updateinfo list security 2>/dev/null")
|
|
144
|
+
if r4_flag.ok:
|
|
145
|
+
r4_sec = r4_flag # the flag form is authoritative: empty output means no security advisories
|
|
146
|
+
elif r4_sec.ok and not r4_sec.stdout.strip() and r4_total.exit_code == 100:
|
|
147
|
+
# Positional form printed nothing while updates ARE pending: on dnf5 that is the broken
|
|
148
|
+
# spec, not "zero security updates". Never PASS on it.
|
|
149
|
+
return self._finding(
|
|
150
|
+
Verdict.SKIP,
|
|
151
|
+
skip_reason=(
|
|
152
|
+
"dnf reports pending updates but `dnf updateinfo list security` returned nothing "
|
|
153
|
+
"(dnf5 needs --security); security status cannot be determined"
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
if r4_sec.ok or r4_sec.stdout:
|
|
157
|
+
sec_lines = [ln for ln in r4_sec.stdout.splitlines() if _is_security_row(ln)]
|
|
158
|
+
# UMBR-39: updateinfo's default ("available") view also reports advisories that only
|
|
159
|
+
# apply to OLDER installed copies of installonly packages (kernel, kernel-core, ...):
|
|
160
|
+
# after a kernel update, before the reboot, a fully patched Rocky 9 showed 132. Keep
|
|
161
|
+
# only advisories for packages check-update would actually upgrade; with no usable
|
|
162
|
+
# check-update answer, count them all (over-report rather than PASS on missing data).
|
|
163
|
+
upgradable = _upgradable(r4_total)
|
|
164
|
+
if upgradable is not None:
|
|
165
|
+
sec_lines = [
|
|
166
|
+
ln for ln in sec_lines
|
|
167
|
+
if _advisory_name_arch(_row_nevra(ln)) in upgradable
|
|
168
|
+
]
|
|
169
|
+
security = len(sec_lines)
|
|
170
|
+
total = 0
|
|
171
|
+
if r4_total.exit_code == 100 and r4_total.stdout:
|
|
172
|
+
total = sum(
|
|
173
|
+
1 for ln in r4_total.stdout.splitlines()
|
|
174
|
+
if ln.strip()
|
|
175
|
+
and not ln.startswith(("Last metadata", "Loaded plugins"))
|
|
176
|
+
and len(ln.split()) >= 3
|
|
177
|
+
)
|
|
178
|
+
# Total can't be less than security (the security ones ARE upgradable)
|
|
179
|
+
total = max(total, security)
|
|
180
|
+
return self._verdict_for_counts(total, security)
|
|
181
|
+
|
|
182
|
+
return self._finding(
|
|
183
|
+
Verdict.SKIP,
|
|
184
|
+
skip_reason=(
|
|
185
|
+
"none of apt-check, apt-get -s upgrade, apt list --upgradable, "
|
|
186
|
+
"or dnf updateinfo produced parseable output"
|
|
187
|
+
),
|
|
188
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Public-facing LISTEN sockets not in the expected allow-list.
|
|
2
|
+
|
|
3
|
+
Catches the 'I forgot Redis was listening on 0.0.0.0' class of mistake.
|
|
4
|
+
The default allow-list is 22, 80, 443. Operators with intentional non-standard
|
|
5
|
+
public listeners can override SCANNER_LISTEN_ALLOWLIST in their env (CSV).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from umbermark.core.access import get_access_context
|
|
13
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
14
|
+
from umbermark.core.firewall import Exposure
|
|
15
|
+
from umbermark.core.ssh import CommandRunner
|
|
16
|
+
|
|
17
|
+
_DEFAULT_ALLOWLIST = {22, 80, 443}
|
|
18
|
+
|
|
19
|
+
# Well-known services that stock distro images bind on every address, with the one-line fix (UMBR-20: Debian 12/13
|
|
20
|
+
# and Fedora images ship systemd-resolved LLMNR on 5355; Rocky/Alma ship rpcbind on 111). They are real
|
|
21
|
+
# listeners reachable from the internet when no firewall drops them, so they stay findings; the hint makes them
|
|
22
|
+
# actionable. No absolute paths here: this text is also shown on scrubbed share links.
|
|
23
|
+
_KNOWN_PORTS = {
|
|
24
|
+
111: "rpcbind (NFS portmapper); if this host serves no NFS: systemctl disable --now rpcbind.socket rpcbind.service",
|
|
25
|
+
5355: "systemd-resolved LLMNR; set LLMNR=no in resolved.conf, then restart systemd-resolved",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_PROC_RE = re.compile(r'users:\(\("([^"]+)"')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bind_scopes(ss_output: str) -> dict[int, set[str]]:
|
|
32
|
+
"""Port -> {'IPv4', 'IPv6'} for wildcard LISTEN sockets. Never the address itself (no IPs in evidence)."""
|
|
33
|
+
out: dict[int, set[str]] = {}
|
|
34
|
+
for line in ss_output.splitlines():
|
|
35
|
+
cols = line.split()
|
|
36
|
+
if len(cols) < 4 or cols[0] != "LISTEN":
|
|
37
|
+
continue
|
|
38
|
+
local = cols[3]
|
|
39
|
+
port = local.rsplit(":", 1)[-1]
|
|
40
|
+
if not port.isdigit():
|
|
41
|
+
continue
|
|
42
|
+
if local.startswith(("0.0.0.0:", "*:")):
|
|
43
|
+
out.setdefault(int(port), set()).add("IPv4" if local.startswith("0.0.0.0:") else "IPv4+IPv6")
|
|
44
|
+
elif local.startswith(("[::]:", ":::")):
|
|
45
|
+
out.setdefault(int(port), set()).add("IPv6")
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _processes(ssp_output: str) -> dict[int, set[str]]:
|
|
50
|
+
"""Port -> process names, from `ss -tlnp` (needs root; empty when unavailable)."""
|
|
51
|
+
out: dict[int, set[str]] = {}
|
|
52
|
+
for line in ssp_output.splitlines():
|
|
53
|
+
cols = line.split()
|
|
54
|
+
if len(cols) < 4 or cols[0] != "LISTEN":
|
|
55
|
+
continue
|
|
56
|
+
port = cols[3].rsplit(":", 1)[-1]
|
|
57
|
+
m = _PROC_RE.search(line)
|
|
58
|
+
if port.isdigit() and m:
|
|
59
|
+
out.setdefault(int(port), set()).add(m.group(1))
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def describe_ports(ports: list[int], ss_output: str, ssp_output: str = "") -> str:
|
|
64
|
+
"""'5355/tcp on all IPv4 and IPv6 addresses (systemd-resolve: systemd-resolved LLMNR; ...)' per port, '; '-joined.
|
|
65
|
+
|
|
66
|
+
Built only from port numbers, bind scope words and process names, so it carries no IP address or path and is
|
|
67
|
+
safe to show on a scrubbed share link (UMBR-20).
|
|
68
|
+
"""
|
|
69
|
+
scopes, procs = _bind_scopes(ss_output), _processes(ssp_output)
|
|
70
|
+
parts = []
|
|
71
|
+
for p in ports:
|
|
72
|
+
s = scopes.get(p, set())
|
|
73
|
+
where = ("all IPv4 and IPv6 addresses" if ("IPv4+IPv6" in s or {"IPv4", "IPv6"} <= s)
|
|
74
|
+
else "all IPv4 addresses" if "IPv4" in s else "all IPv6 addresses" if "IPv6" in s else "a public address")
|
|
75
|
+
detail = []
|
|
76
|
+
if procs.get(p):
|
|
77
|
+
detail.append("process " + ", ".join(sorted(procs[p])))
|
|
78
|
+
if p in _KNOWN_PORTS:
|
|
79
|
+
detail.append(_KNOWN_PORTS[p])
|
|
80
|
+
parts.append(f"{p}/tcp on {where}" + (f" ({'; '.join(detail)})" if detail else ""))
|
|
81
|
+
return "; ".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _public_listen_ports(ss_output: str) -> list[int]:
|
|
85
|
+
"""Extract ports from `ss -tln` LISTEN lines bound to 0.0.0.0 / * / ::."""
|
|
86
|
+
ports: list[int] = []
|
|
87
|
+
for line in ss_output.splitlines():
|
|
88
|
+
if not line.startswith("LISTEN"):
|
|
89
|
+
continue
|
|
90
|
+
# The Local Address:Port column is the 5th whitespace-separated field
|
|
91
|
+
# in `ss -tln` output (State, Recv-Q, Send-Q, Local Addr:Port, Peer).
|
|
92
|
+
cols = line.split()
|
|
93
|
+
if len(cols) < 4:
|
|
94
|
+
continue
|
|
95
|
+
local = cols[3]
|
|
96
|
+
# IPv6: [::]:PORT or :::PORT
|
|
97
|
+
# IPv4: 0.0.0.0:PORT or *:PORT
|
|
98
|
+
m = re.match(r"^(?:0\.0\.0\.0|\*|\[::\]|:::)(?::|)(\d+)$", local)
|
|
99
|
+
if m:
|
|
100
|
+
try:
|
|
101
|
+
ports.append(int(m.group(1)))
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
continue
|
|
105
|
+
# Older ss versions render '*:PORT' without brackets
|
|
106
|
+
if local.startswith(("0.0.0.0:", "*:", "[::]:", ":::")):
|
|
107
|
+
tail = local.rsplit(":", 1)[-1]
|
|
108
|
+
if tail.isdigit():
|
|
109
|
+
ports.append(int(tail))
|
|
110
|
+
return ports
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PublicListenAnomaliesCheck(Check):
|
|
114
|
+
id = "public_listen_anomalies"
|
|
115
|
+
title = "Public-facing LISTEN sockets are on expected ports only"
|
|
116
|
+
category = "services"
|
|
117
|
+
severity = Severity.HIGH
|
|
118
|
+
weight = 10
|
|
119
|
+
applicable_distros = ()
|
|
120
|
+
remediation = (
|
|
121
|
+
"Close or restrict each unexpected public listener, since each is reachable\n"
|
|
122
|
+
"from the internet:\n"
|
|
123
|
+
" 1. Bind to 127.0.0.1 if the service only needs local consumers\n"
|
|
124
|
+
" (most databases, cache servers, internal admin UIs)\n"
|
|
125
|
+
" 2. Restrict at the firewall with explicit allow rules from known IPs\n"
|
|
126
|
+
" 3. If the listener IS intended public, add its port to the env var:\n"
|
|
127
|
+
" SCANNER_LISTEN_ALLOWLIST=22,80,443,2222,5432"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
131
|
+
r = runner.run("ss -tln 2>/dev/null", sudo=True, timeout=10)
|
|
132
|
+
if not r.ok or not r.stdout.strip():
|
|
133
|
+
return self._finding(
|
|
134
|
+
Verdict.SKIP, skip_reason="ss -tln produced no output"
|
|
135
|
+
)
|
|
136
|
+
ports = _public_listen_ports(r.stdout)
|
|
137
|
+
allowlist = self._allowlist_from_env()
|
|
138
|
+
unexpected = sorted(set(p for p in ports if p not in allowlist))
|
|
139
|
+
# Judge exposure AFTER the firewall: a wildcard-bound socket the host
|
|
140
|
+
# firewall drops is not reachable, so it is not an exposure finding.
|
|
141
|
+
ctx = get_access_context(runner)
|
|
142
|
+
fw = ctx.firewall
|
|
143
|
+
firewalled: list[int] = []
|
|
144
|
+
if fw.readable and unexpected:
|
|
145
|
+
# An INPUT drop proves nothing for ports reachable via DNAT / published ports
|
|
146
|
+
# (docker, rootful podman): keep those findings.
|
|
147
|
+
firewalled = [
|
|
148
|
+
p for p in unexpected
|
|
149
|
+
if fw.evaluate(p, "tcp").state == Exposure.BLOCKED and not ctx.dnat_suspect(p)
|
|
150
|
+
]
|
|
151
|
+
unexpected = [p for p in unexpected if p not in firewalled]
|
|
152
|
+
ssp = ""
|
|
153
|
+
if firewalled or unexpected: # name the process behind each port (UMBR-20); root-only, best effort
|
|
154
|
+
rp = runner.run("ss -tlnp 2>/dev/null", sudo=True, timeout=10)
|
|
155
|
+
ssp = rp.stdout if rp.ok else ""
|
|
156
|
+
if firewalled and not unexpected:
|
|
157
|
+
labels = describe_ports(firewalled, r.stdout, ssp)
|
|
158
|
+
return self._finding(
|
|
159
|
+
Verdict.WARN,
|
|
160
|
+
evidence=(
|
|
161
|
+
f"Informational: wildcard-bound LISTEN port(s) {labels} are not on the allow-list "
|
|
162
|
+
"but the host firewall drops them, so they are not reachable. Consider binding "
|
|
163
|
+
"them to loopback or disabling the listener."
|
|
164
|
+
),
|
|
165
|
+
severity=Severity.LOW,
|
|
166
|
+
weight=0,
|
|
167
|
+
remediation=(
|
|
168
|
+
"Optional tidy-up, not an exposure: the firewall already blocks these ports.\n"
|
|
169
|
+
"Bind the service to 127.0.0.1 or disable the listener so a future firewall\n"
|
|
170
|
+
"change cannot expose it (for systemd-resolved LLMNR: LLMNR=no in\n"
|
|
171
|
+
"/etc/systemd/resolved.conf, then restart systemd-resolved)."
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
if not unexpected:
|
|
175
|
+
return self._finding(
|
|
176
|
+
Verdict.PASS,
|
|
177
|
+
evidence=f"{len(ports)} public LISTEN sockets, all on allow-listed ports {sorted(allowlist)}",
|
|
178
|
+
)
|
|
179
|
+
allowed = ", ".join(str(p) for p in sorted(allowlist))
|
|
180
|
+
if len(unexpected) == 1:
|
|
181
|
+
return self._finding(
|
|
182
|
+
Verdict.WARN,
|
|
183
|
+
evidence=(f"1 unexpected public LISTEN: port {describe_ports(unexpected, r.stdout, ssp)}. "
|
|
184
|
+
f"Allow-listed ports: {allowed}"),
|
|
185
|
+
)
|
|
186
|
+
return self._finding(
|
|
187
|
+
Verdict.FAIL,
|
|
188
|
+
evidence=(f"{len(unexpected)} unexpected public LISTEN ports: {describe_ports(unexpected[:8], r.stdout, ssp)}. "
|
|
189
|
+
f"Allow-listed ports: {allowed}"),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _allowlist_from_env() -> set[int]:
|
|
194
|
+
raw = os.environ.get("SCANNER_LISTEN_ALLOWLIST", "")
|
|
195
|
+
if not raw:
|
|
196
|
+
return set(_DEFAULT_ALLOWLIST)
|
|
197
|
+
try:
|
|
198
|
+
return {int(p.strip()) for p in raw.split(",") if p.strip().isdigit()}
|
|
199
|
+
except ValueError:
|
|
200
|
+
return set(_DEFAULT_ALLOWLIST)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Reboot-pending state — pending kernel/init update awaiting a reboot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
7
|
+
from umbermark.core.platform import _family_for, _parse_os_release
|
|
8
|
+
from umbermark.core.ssh import CommandRunner
|
|
9
|
+
|
|
10
|
+
_REBOOT_MSG = re.compile(r"reboot is required", re.I)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RebootPendingCheck(Check):
|
|
14
|
+
id = "reboot_pending"
|
|
15
|
+
title = "Reboot required (kernel / init update pending)"
|
|
16
|
+
category = "patching"
|
|
17
|
+
severity = Severity.HIGH
|
|
18
|
+
weight = 10
|
|
19
|
+
applicable_distros = ("debian", "rhel")
|
|
20
|
+
remediation = (
|
|
21
|
+
"Schedule a reboot at a convenient maintenance window:\n"
|
|
22
|
+
" sudo shutdown -r +5 'kernel update reboot'\n"
|
|
23
|
+
"Until you reboot, you're running the OLD kernel even though the new\n"
|
|
24
|
+
"one is installed on disk. Security updates that require a reboot\n"
|
|
25
|
+
"(notably kernel CVEs) are NOT in effect yet."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
29
|
+
# All three commands are issued up front (UMBR-31). The flag-file test answers "no" on
|
|
30
|
+
# RHEL too (the file never exists there), so returning on it first meant the dnf branch
|
|
31
|
+
# was unreachable on every RHEL host and a pending kernel reboot scored PASS.
|
|
32
|
+
r = runner.run(
|
|
33
|
+
"[ -f /var/run/reboot-required ] && echo yes || echo no", timeout=5
|
|
34
|
+
)
|
|
35
|
+
# RHEL family: dnf needs-restarting -r exits 1 if a reboot is required, 0 if not.
|
|
36
|
+
r2 = runner.run("dnf needs-restarting -r 2>/dev/null; echo exit:$?", sudo=True, timeout=10)
|
|
37
|
+
# Already captured by platform detection, so bundles replay it without a new command.
|
|
38
|
+
osr = runner.run("cat /etc/os-release 2>/dev/null", timeout=5)
|
|
39
|
+
family = "unknown"
|
|
40
|
+
if osr.ok and osr.stdout:
|
|
41
|
+
parsed = _parse_os_release(osr.stdout)
|
|
42
|
+
family = _family_for(parsed.get("ID", ""), parsed.get("ID_LIKE", ""))
|
|
43
|
+
|
|
44
|
+
flag = r.stdout.strip() if r.ok else ""
|
|
45
|
+
if flag == "yes":
|
|
46
|
+
return self._finding(Verdict.FAIL, evidence="/var/run/reboot-required exists")
|
|
47
|
+
|
|
48
|
+
out = r2.stdout if r2.ok else ""
|
|
49
|
+
ec = out.strip().rsplit("exit:", 1)[-1] if "exit:" in out else None
|
|
50
|
+
# Exit 1 alone is ambiguous: over SSH the runner prefixes `sudo -n`, so on a host without
|
|
51
|
+
# dnf (or without passwordless sudo) `sudo -n dnf ...; echo exit:$?` also prints exit:1.
|
|
52
|
+
# dnf (4 and 5) prints this message when, and only when, a reboot is required.
|
|
53
|
+
if ec == "1" and _REBOOT_MSG.search(out):
|
|
54
|
+
return self._finding(
|
|
55
|
+
Verdict.FAIL, evidence="dnf needs-restarting -r: reboot required"
|
|
56
|
+
)
|
|
57
|
+
if ec == "0":
|
|
58
|
+
return self._finding(
|
|
59
|
+
Verdict.PASS, evidence="dnf needs-restarting -r: no reboot needed"
|
|
60
|
+
)
|
|
61
|
+
# The flag file only speaks for Debian/Ubuntu; on RHEL its "no" says nothing.
|
|
62
|
+
if flag == "no" and family != "rhel":
|
|
63
|
+
return self._finding(Verdict.PASS, evidence="no pending reboot")
|
|
64
|
+
return self._finding(
|
|
65
|
+
Verdict.SKIP,
|
|
66
|
+
skip_reason=(
|
|
67
|
+
"dnf needs-restarting -r gave no answer (needs root and dnf-plugins-core)"
|
|
68
|
+
if family == "rhel"
|
|
69
|
+
else "neither Debian nor dnf reboot-required check succeeded"
|
|
70
|
+
),
|
|
71
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Package repos have valid GPG signing trust configured."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
5
|
+
from umbermark.core.ssh import CommandRunner
|
|
6
|
+
|
|
7
|
+
# Keyring files that apt sources actually name: deb822 "Signed-By:" lines and
|
|
8
|
+
# one-line "[signed-by=...]" options. Only referenced paths that exist count, so
|
|
9
|
+
# distro keyring packages (including *-removed-keys.gpg) are not evidence.
|
|
10
|
+
SIGNED_BY_CMD = (
|
|
11
|
+
"{ grep -hiE '^[[:space:]]*Signed-By:' /etc/apt/sources.list.d/*.sources"
|
|
12
|
+
" 2>/dev/null | sed -E 's/^[^:]*://';"
|
|
13
|
+
" grep -hoE 'signed-by=[^] ]+' /etc/apt/sources.list"
|
|
14
|
+
" /etc/apt/sources.list.d/*.list 2>/dev/null | sed 's/^signed-by=//'; }"
|
|
15
|
+
" | tr ', ' '\\n\\n' | grep '^/' | sort -u"
|
|
16
|
+
' | while read -r f; do [ -f "$f" ] && echo "$f"; done | wc -l'
|
|
17
|
+
)
|
|
18
|
+
# Legacy single-file apt keyring (non-empty file => 1).
|
|
19
|
+
LEGACY_CMD = "[ -s /etc/apt/trusted.gpg ] && echo 1 || echo 0"
|
|
20
|
+
HAS_APT_CMD = "command -v apt-get"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _num(res):
|
|
24
|
+
out = res.stdout.strip()
|
|
25
|
+
return int(out) if res.ok and out.isdigit() else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RepoGpgTrustCheck(Check):
|
|
29
|
+
id = "repo_gpg_trust"
|
|
30
|
+
title = "Package repos have signing keys configured"
|
|
31
|
+
category = "patching"
|
|
32
|
+
severity = Severity.MEDIUM
|
|
33
|
+
weight = 5
|
|
34
|
+
applicable_distros = ("debian", "rhel")
|
|
35
|
+
remediation = (
|
|
36
|
+
"Restore the distribution's signing keys before installing more packages:\n"
|
|
37
|
+
"Debian/Ubuntu: ensure /etc/apt/trusted.gpg.d/ contains the distro key set\n"
|
|
38
|
+
" sudo apt install --reinstall ubuntu-keyring debian-archive-keyring\n"
|
|
39
|
+
"RHEL/Rocky: ensure gpg-pubkey package(s) are present\n"
|
|
40
|
+
" sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-<distro>"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
44
|
+
# Every probe is issued unconditionally so capture enumeration (which
|
|
45
|
+
# runs checks under an all-failing runner) sees every command.
|
|
46
|
+
trusted = _num(runner.run("ls /etc/apt/trusted.gpg.d/ 2>/dev/null | wc -l", timeout=5))
|
|
47
|
+
signed_by = _num(runner.run(SIGNED_BY_CMD, timeout=10))
|
|
48
|
+
legacy = _num(runner.run(LEGACY_CMD, timeout=5))
|
|
49
|
+
has_apt = runner.run(HAS_APT_CMD, timeout=5)
|
|
50
|
+
has_rpm = runner.run("command -v rpm", timeout=5)
|
|
51
|
+
rpm_count = _num(runner.run("rpm -qa gpg-pubkey 2>/dev/null | wc -l", timeout=5))
|
|
52
|
+
|
|
53
|
+
if trusted:
|
|
54
|
+
return self._finding(
|
|
55
|
+
Verdict.PASS, evidence=f"apt trusted-gpg keyrings: {trusted} file(s)"
|
|
56
|
+
)
|
|
57
|
+
# Debian 12+/Ubuntu 22.04+ keep keys in keyring files that sources
|
|
58
|
+
# reference via Signed-By, so an empty trusted.gpg.d is normal there.
|
|
59
|
+
# Only keyrings a source really names (and that exist) count.
|
|
60
|
+
if signed_by:
|
|
61
|
+
return self._finding(
|
|
62
|
+
Verdict.PASS,
|
|
63
|
+
evidence=f"apt Signed-By keyring files referenced by sources: {signed_by} file(s)",
|
|
64
|
+
)
|
|
65
|
+
if legacy:
|
|
66
|
+
return self._finding(
|
|
67
|
+
Verdict.PASS, evidence="apt legacy keyring /etc/apt/trusted.gpg present"
|
|
68
|
+
)
|
|
69
|
+
# A positive pubkey count proves an RPM host even when an older capture
|
|
70
|
+
# lacks the `command -v rpm` probe.
|
|
71
|
+
if rpm_count:
|
|
72
|
+
return self._finding(
|
|
73
|
+
Verdict.PASS, evidence=f"rpm gpg-pubkey packages: {rpm_count}"
|
|
74
|
+
)
|
|
75
|
+
if has_apt.ok:
|
|
76
|
+
return self._finding(
|
|
77
|
+
Verdict.FAIL,
|
|
78
|
+
evidence=(
|
|
79
|
+
"apt: no keys in /etc/apt/trusted.gpg.d, /etc/apt/trusted.gpg, or any "
|
|
80
|
+
"existing Signed-By keyring named by the apt sources — repo signature "
|
|
81
|
+
"trust is broken (or sources use trusted=yes)"
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
# No apt and no rpm binary: not a host this check can judge (a missing
|
|
85
|
+
# rpm prints "0" via wc, which must never become FAIL).
|
|
86
|
+
if not has_rpm.ok:
|
|
87
|
+
return self._finding(
|
|
88
|
+
Verdict.SKIP, skip_reason="neither apt nor rpm is installed"
|
|
89
|
+
)
|
|
90
|
+
if rpm_count == 0:
|
|
91
|
+
return self._finding(
|
|
92
|
+
Verdict.FAIL,
|
|
93
|
+
evidence="rpm: zero gpg-pubkey packages installed — repo signature trust is broken",
|
|
94
|
+
)
|
|
95
|
+
return self._finding(
|
|
96
|
+
Verdict.SKIP, skip_reason="neither apt nor rpm keyring query produced a number"
|
|
97
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Root disk free space — % used threshold."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
7
|
+
from umbermark.core.ssh import CommandRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RootDiskFreeCheck(Check):
|
|
11
|
+
id = "root_disk_free"
|
|
12
|
+
title = "Root disk has free space"
|
|
13
|
+
category = "filesystem"
|
|
14
|
+
severity = Severity.HIGH
|
|
15
|
+
weight = 8
|
|
16
|
+
applicable_distros = ()
|
|
17
|
+
remediation = (
|
|
18
|
+
"Free up space on the root filesystem. Common offenders:\n"
|
|
19
|
+
" sudo journalctl --vacuum-time=7d # trim systemd logs\n"
|
|
20
|
+
" sudo apt clean && sudo apt autoremove # apt cache + orphaned packages\n"
|
|
21
|
+
" sudo docker system prune -a # if Docker is installed\n"
|
|
22
|
+
"Servers can lock up entirely when / hits 100%; address before it does."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
26
|
+
# `df -P /` is the POSIX-portable form (line 2 = the data, field 5 = Capacity%).
|
|
27
|
+
# The previous "df -P / --output=pcent" was rejected by modern coreutils
|
|
28
|
+
# ("options -P and --output are mutually exclusive") — surfaced by the
|
|
29
|
+
# 2026-05-15 WSL Dogfood Run #3.
|
|
30
|
+
r = runner.run("df -P / 2>/dev/null | awk 'NR==2 {print $5}'", timeout=5)
|
|
31
|
+
if not r.ok or not r.stdout.strip():
|
|
32
|
+
return self._finding(
|
|
33
|
+
Verdict.SKIP, skip_reason="df / failed or produced no data"
|
|
34
|
+
)
|
|
35
|
+
m = re.search(r"(\d+)%", r.stdout)
|
|
36
|
+
if not m:
|
|
37
|
+
return self._finding(
|
|
38
|
+
Verdict.SKIP, skip_reason=f"df output unparseable: {r.stdout!r}"
|
|
39
|
+
)
|
|
40
|
+
pct = int(m.group(1))
|
|
41
|
+
evidence = f"root disk {pct}% used"
|
|
42
|
+
if pct < 80:
|
|
43
|
+
return self._finding(Verdict.PASS, evidence=evidence)
|
|
44
|
+
if pct < 95:
|
|
45
|
+
return self._finding(Verdict.WARN, evidence=evidence + " — getting tight")
|
|
46
|
+
return self._finding(Verdict.FAIL, evidence=evidence + " — imminent full")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""SSH: informational check — is sshd listening on the default port 22?
|
|
2
|
+
|
|
3
|
+
Weight 1 + WARN-on-default makes this near-zero impact on the score; it
|
|
4
|
+
exists to inform the report rather than to ding it. Changing SSH port
|
|
5
|
+
is mild obscurity, not real security; most hardening guides treat this
|
|
6
|
+
as optional.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from umbermark.core.access import get_access_context, is_nonpublic
|
|
13
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
14
|
+
from umbermark.core.ssh import CommandRunner
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SSHNonDefaultPortCheck(Check):
|
|
18
|
+
id = "ssh_non_default_port"
|
|
19
|
+
title = "SSH listening on a non-default port (informational)"
|
|
20
|
+
category = "identity_auth"
|
|
21
|
+
severity = Severity.LOW
|
|
22
|
+
weight = 1
|
|
23
|
+
applicable_distros = ()
|
|
24
|
+
remediation = (
|
|
25
|
+
"Optionally move SSH off port 22 in /etc/ssh/sshd_config. This cuts scan\n"
|
|
26
|
+
"noise but is NOT real security:\n"
|
|
27
|
+
" Port 2222 # any non-22 port you control firewall access to\n"
|
|
28
|
+
"and remember to allow the new port in your firewall."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
32
|
+
r = runner.run("sshd -T 2>/dev/null", sudo=True, timeout=10)
|
|
33
|
+
if not r.ok or not r.stdout:
|
|
34
|
+
return self._finding(
|
|
35
|
+
Verdict.SKIP,
|
|
36
|
+
skip_reason="could not run sshd -T (requires root and openssh-server)",
|
|
37
|
+
)
|
|
38
|
+
m = re.search(r"^port\s+(\d+)", r.stdout, re.M | re.I)
|
|
39
|
+
if not m:
|
|
40
|
+
return self._finding(
|
|
41
|
+
Verdict.SKIP, skip_reason="no port line in sshd -T output"
|
|
42
|
+
)
|
|
43
|
+
port = int(m.group(1))
|
|
44
|
+
if port == 22:
|
|
45
|
+
ctx = get_access_context(runner)
|
|
46
|
+
if is_nonpublic(ctx):
|
|
47
|
+
return self._finding(
|
|
48
|
+
Verdict.PASS,
|
|
49
|
+
evidence=(
|
|
50
|
+
f"SSH on default port 22, but not publicly reachable "
|
|
51
|
+
f"({ctx.reachability.value}: {ctx.reachability_note}); "
|
|
52
|
+
"a port change would add nothing"
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
return self._finding(
|
|
56
|
+
Verdict.WARN,
|
|
57
|
+
evidence="SSH on default port 22 (informational; not a real vulnerability)",
|
|
58
|
+
)
|
|
59
|
+
return self._finding(
|
|
60
|
+
Verdict.PASS,
|
|
61
|
+
evidence=f"SSH on non-default port {port}",
|
|
62
|
+
)
|