umbermark-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umbermark/__init__.py +18 -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 +14 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/branding.py +71 -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/evidence.py +83 -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 +308 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +55 -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/remediation.py +121 -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 +138 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0.dist-info/RECORD +96 -0
- umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""White-label branding and client links (UMBR-66; Agency).
|
|
2
|
+
|
|
3
|
+
The brand (name, accent colour, optional PNG logo) goes on the owner PDF and on client links. The logo is checked
|
|
4
|
+
here before it is stored: PNG signature, a well-formed IHDR, at most MAX_LOGO_BYTES and MAX_LOGO_SIDE pixels a side.
|
|
5
|
+
Only PNG is accepted (no SVG: it can carry script). It is served inline as a data: URI, never fetched from a URL, so
|
|
6
|
+
the PDF renderer makes no outbound request for it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import struct
|
|
15
|
+
|
|
16
|
+
MAX_LOGO_BYTES = 100_000
|
|
17
|
+
MAX_LOGO_SIDE = 1024
|
|
18
|
+
NAME_MAX = 60
|
|
19
|
+
DEFAULT_ACCENT = "#3b82f6"
|
|
20
|
+
ACCENT_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
21
|
+
LINK_PREFIX = "umk_cl_"
|
|
22
|
+
_PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BrandError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_png(data: bytes) -> tuple[int, int]:
|
|
30
|
+
"""(width, height) of a valid PNG within the limits; BrandError otherwise."""
|
|
31
|
+
if not data:
|
|
32
|
+
raise BrandError("Choose a PNG file")
|
|
33
|
+
if len(data) > MAX_LOGO_BYTES:
|
|
34
|
+
raise BrandError(f"The logo must be at most {MAX_LOGO_BYTES // 1000} KB")
|
|
35
|
+
if len(data) < 33 or data[:8] != _PNG_SIG or data[12:16] != b"IHDR":
|
|
36
|
+
raise BrandError("The logo must be a PNG image")
|
|
37
|
+
w, h = struct.unpack(">II", data[16:24])
|
|
38
|
+
if not (0 < w <= MAX_LOGO_SIDE and 0 < h <= MAX_LOGO_SIDE):
|
|
39
|
+
raise BrandError(f"The logo must be at most {MAX_LOGO_SIDE} x {MAX_LOGO_SIDE} pixels")
|
|
40
|
+
return w, h
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def data_uri(png: bytes | None) -> str | None:
|
|
44
|
+
return ("data:image/png;base64," + base64.b64encode(bytes(png)).decode()) if png else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_name(name: str) -> str:
|
|
48
|
+
return re.sub(r"\s+", " ", name or "").strip()[:NAME_MAX]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clean_accent(accent: str) -> str:
|
|
52
|
+
a = (accent or "").strip()
|
|
53
|
+
if not ACCENT_RE.match(a):
|
|
54
|
+
raise BrandError("The colour must look like #1a2b3c")
|
|
55
|
+
return a.lower()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def new_link_token() -> tuple[str, str]:
|
|
59
|
+
t = LINK_PREFIX + secrets.token_urlsafe(24)
|
|
60
|
+
return t, hashlib.sha256(t.encode()).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hash_link_token(t: str) -> str:
|
|
64
|
+
return hashlib.sha256(t.encode()).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def for_render(row) -> dict | None:
|
|
68
|
+
"""The brand dict templates use ({name, accent, logo}), or None."""
|
|
69
|
+
if row is None:
|
|
70
|
+
return None
|
|
71
|
+
return {"name": row["name"], "accent": row["accent"], "logo": data_uri(row["logo_png"])}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Tier-2 'run & upload' support.
|
|
2
|
+
|
|
3
|
+
A client who won't grant SSH access can instead run a small, auditable,
|
|
4
|
+
read-only script on their own box and upload its output. That output is a
|
|
5
|
+
*command bundle* — a map of the exact shell commands the checks issue to the
|
|
6
|
+
stdout/exit_code they produced. Replaying the bundle through the same checks
|
|
7
|
+
(`engine.run_scan`) yields the identical report a live SSH scan would, with
|
|
8
|
+
zero access granted.
|
|
9
|
+
|
|
10
|
+
The command set is enumerated from the checks themselves (see
|
|
11
|
+
`enumerate_commands`) so the generated script can never drift from the catalog.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import binascii
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from umbermark.core.ssh import CommandResult, FakeRunner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _stdout_of(rec: dict[str, Any]) -> str:
|
|
23
|
+
"""Resolve a captured command's stdout.
|
|
24
|
+
|
|
25
|
+
The generated script base64-encodes stdout (``stdout_b64``) so arbitrary
|
|
26
|
+
output survives JSON transport without escaping hazards; hand-written
|
|
27
|
+
bundles (and tests) may use a plain ``stdout`` instead.
|
|
28
|
+
"""
|
|
29
|
+
if "stdout_b64" in rec:
|
|
30
|
+
try:
|
|
31
|
+
return base64.b64decode(rec["stdout_b64"]).decode("utf-8", "replace")
|
|
32
|
+
except (binascii.Error, ValueError):
|
|
33
|
+
return ""
|
|
34
|
+
return rec.get("stdout", "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CollectingRunner:
|
|
38
|
+
"""A runner that records every requested command and always reports failure.
|
|
39
|
+
|
|
40
|
+
Returning a failure result (exit 127) forces each check down its full
|
|
41
|
+
fallback chain, so a single pass over the catalog requests the *superset* of
|
|
42
|
+
commands any check could issue. Used only to enumerate the command set for
|
|
43
|
+
the capture script — it never executes anything.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.calls: list[tuple[str, bool]] = []
|
|
48
|
+
|
|
49
|
+
def run(self, command: str, sudo: bool = False, timeout: float = 30.0) -> CommandResult:
|
|
50
|
+
self.calls.append((command, bool(sudo)))
|
|
51
|
+
return CommandResult(stdout="", stderr="", exit_code=127)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def enumerate_commands() -> list[dict[str, Any]]:
|
|
55
|
+
"""Enumerate every shell command a full scan issues, deduplicated, in order.
|
|
56
|
+
|
|
57
|
+
Drives directly off the live check catalog + platform detector, so the
|
|
58
|
+
generated capture script can never drift from what the checks actually run.
|
|
59
|
+
Each entry is ``{"command": str, "sudo": bool}`` where ``sudo`` is true if
|
|
60
|
+
*any* check issues that command with sudo.
|
|
61
|
+
"""
|
|
62
|
+
from umbermark.checks import ALL_CHECKS
|
|
63
|
+
from umbermark.core.platform import detect_platform
|
|
64
|
+
|
|
65
|
+
collector = CollectingRunner()
|
|
66
|
+
detect_platform(collector)
|
|
67
|
+
for check in ALL_CHECKS:
|
|
68
|
+
try:
|
|
69
|
+
check.run(collector)
|
|
70
|
+
except Exception:
|
|
71
|
+
# A check raising under all-failure input still recorded its commands.
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
ordered: dict[str, dict[str, Any]] = {}
|
|
75
|
+
for command, sudo in collector.calls:
|
|
76
|
+
if command in ordered:
|
|
77
|
+
ordered[command]["sudo"] = ordered[command]["sudo"] or sudo
|
|
78
|
+
else:
|
|
79
|
+
ordered[command] = {"command": command, "sudo": sudo}
|
|
80
|
+
return list(ordered.values())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_replay_runner(bundle: dict[str, Any]) -> FakeRunner:
|
|
84
|
+
"""Build a CommandRunner that serves captured output from a command bundle.
|
|
85
|
+
|
|
86
|
+
Bundle shape: ``{"commands": {"<bare cmd>": {"stdout": str, "exit_code": int,
|
|
87
|
+
"stderr": str?}}}``. Commands absent from the bundle resolve to a failure
|
|
88
|
+
result (exit 127), so the corresponding check degrades to SKIP — exactly as
|
|
89
|
+
`FakeRunner` does for an unregistered command.
|
|
90
|
+
"""
|
|
91
|
+
commands = bundle.get("commands", {}) or {}
|
|
92
|
+
responses: dict[str, CommandResult] = {}
|
|
93
|
+
for cmd, rec in commands.items():
|
|
94
|
+
responses[cmd] = CommandResult(
|
|
95
|
+
stdout=_stdout_of(rec),
|
|
96
|
+
stderr=rec.get("stderr", ""),
|
|
97
|
+
exit_code=int(rec.get("exit_code", 0)),
|
|
98
|
+
)
|
|
99
|
+
return FakeRunner(responses=responses)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _sh_squote(s: str) -> str:
|
|
103
|
+
"""POSIX single-quote a string for safe embedding as one shell word."""
|
|
104
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_SCRIPT_TEMPLATE = """\
|
|
108
|
+
#!/usr/bin/env bash
|
|
109
|
+
# =============================================================================
|
|
110
|
+
# Umbermark read-only scan collector -- REVIEW THIS SCRIPT BEFORE RUNNING.
|
|
111
|
+
#
|
|
112
|
+
# It runs ONLY the read-only diagnostic commands listed below (each passed to
|
|
113
|
+
# `_run`) and writes a JSON bundle of their output to stdout. It changes NOTHING
|
|
114
|
+
# on this host: no installs, no config edits (the only write is a temp file
|
|
115
|
+
# holding your bundle, and only if an upload fails). Upload the bundle to
|
|
116
|
+
# Umbermark to get your hardening score -- we never touch your server.
|
|
117
|
+
#
|
|
118
|
+
# Run with sudo (or as root) for full coverage of privileged checks:
|
|
119
|
+
# sudo bash umbermark-collect.sh
|
|
120
|
+
# =============================================================================
|
|
121
|
+
set -u
|
|
122
|
+
TARGET=${1:-@@TARGET@@}
|
|
123
|
+
|
|
124
|
+
_jstr() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')"; }
|
|
125
|
+
_run() { # _run <command-string> <needs_sudo:0|1>
|
|
126
|
+
cmd="$1"; need="$2"
|
|
127
|
+
if [ "$need" = "1" ] && [ "$(id -u)" != "0" ]; then
|
|
128
|
+
out="$(sudo -n sh -c "$cmd" 2>/dev/null)"; rc=$?
|
|
129
|
+
else
|
|
130
|
+
out="$(sh -c "$cmd" 2>/dev/null)"; rc=$?
|
|
131
|
+
fi
|
|
132
|
+
printf '%s%s:{"exit_code":%s,"stdout_b64":"%s"}' \\
|
|
133
|
+
"$SEP" "$(_jstr "$cmd")" "$rc" "$(printf '%s' "$out" | base64 | tr -d '\\n')"
|
|
134
|
+
SEP=','
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@@PROGRESS@@
|
|
138
|
+
BUNDLE="$(
|
|
139
|
+
printf '{"schema_version":1,"target":%s,"commands":{' "$(_jstr "$TARGET")"
|
|
140
|
+
SEP=''
|
|
141
|
+
@@RUNS@@
|
|
142
|
+
printf '}}'
|
|
143
|
+
)"
|
|
144
|
+
@@OUTPUT@@"""
|
|
145
|
+
|
|
146
|
+
# Autoupload tail: POST the bundle, print the server's plain-text summary
|
|
147
|
+
# (score/band + share URL) as the final lines. bash + curl + coreutils only.
|
|
148
|
+
# `-w` appends the HTTP status after the body; curl exits non-zero on network
|
|
149
|
+
# failure, in which case the status is 000. Bundle goes via stdin (`@-`) so a
|
|
150
|
+
# large bundle can't hit the argv size limit.
|
|
151
|
+
_AUTOUPLOAD_TEMPLATE = r"""RESP="$(printf '%s' "$BUNDLE" | curl -sS -X POST \
|
|
152
|
+
-H "Content-Type: application/json" -H "Accept: text/plain"@@AUTH@@ \
|
|
153
|
+
--data-binary @- -w '\n%{http_code}' @@URL@@ 2>&1)"
|
|
154
|
+
CURL_RC=$?
|
|
155
|
+
CODE="${RESP##*$'\n'}"
|
|
156
|
+
BODY="${RESP%$'\n'*}"
|
|
157
|
+
case "$CODE" in 2??) OK=1 ;; *) OK=0 ;; esac
|
|
158
|
+
if [ "$CURL_RC" -eq 0 ] && [ "$OK" = "1" ]; then
|
|
159
|
+
printf '%s\n' "$BODY"
|
|
160
|
+
elif [ "$CURL_RC" -eq 0 ] && [ "$CODE" = "429" ]; then
|
|
161
|
+
echo "[umbermark] $BODY" >&2
|
|
162
|
+
exit 1
|
|
163
|
+
else
|
|
164
|
+
[ "$CURL_RC" -ne 0 ] && CODE="000 (network error: $BODY)"
|
|
165
|
+
echo "[umbermark] ERROR: upload failed, HTTP status $CODE" >&2
|
|
166
|
+
FALLBACK="$(mktemp 2>/dev/null || echo /tmp/umbermark-bundle.json)"
|
|
167
|
+
printf '%s\n' "$BUNDLE" > "$FALLBACK" 2>/dev/null
|
|
168
|
+
echo "[umbermark] Your bundle was saved to $FALLBACK" >&2
|
|
169
|
+
echo "[umbermark] Paste its contents at the upload page to get your report manually." >&2
|
|
170
|
+
exit 1
|
|
171
|
+
fi
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def generate_capture_script(target: str | None = None, upload_url: str | None = None,
|
|
176
|
+
header_file: str | None = None) -> str:
|
|
177
|
+
"""Generate the auditable, read-only bash collector for Tier-2 'run & upload'.
|
|
178
|
+
|
|
179
|
+
The script runs exactly the commands `enumerate_commands` reports (so it
|
|
180
|
+
never drifts from the catalog), base64-encodes each stdout for escaping-safe
|
|
181
|
+
JSON transport. Without ``upload_url`` it prints the bundle to stdout (for
|
|
182
|
+
``> file`` usage). With ``upload_url`` it does NOT print the bundle; it POSTs
|
|
183
|
+
it and prints the score, band and share URL instead (bundle is saved to a
|
|
184
|
+
temp file only if the upload fails).
|
|
185
|
+
"""
|
|
186
|
+
commands = enumerate_commands()
|
|
187
|
+
runs = "\n".join(
|
|
188
|
+
" _run {cmd} {sudo}".format(cmd=_sh_squote(c["command"]), sudo=1 if c["sudo"] else 0)
|
|
189
|
+
for c in commands
|
|
190
|
+
)
|
|
191
|
+
if upload_url:
|
|
192
|
+
# The announced check count is len(ALL_CHECKS): every check yields exactly
|
|
193
|
+
# one result row (PASS/WARN/FAIL/SKIP), so it equals the report's tally.
|
|
194
|
+
# Commands are a separate, larger number (checks share/fallback commands).
|
|
195
|
+
from umbermark.checks import ALL_CHECKS
|
|
196
|
+
|
|
197
|
+
progress = (
|
|
198
|
+
f'echo "[umbermark] Running {len(ALL_CHECKS)} read-only checks '
|
|
199
|
+
f'({len(commands)} commands)..." >&2'
|
|
200
|
+
)
|
|
201
|
+
# UMBR-66 check-ins: curl reads the Authorization header from a root-only file (-H @file), so the
|
|
202
|
+
# token never appears in a process list or in this script.
|
|
203
|
+
auth = f" -H @{_sh_squote(header_file)}" if header_file else ""
|
|
204
|
+
output = _AUTOUPLOAD_TEMPLATE.replace("@@URL@@", _sh_squote(upload_url)).replace("@@AUTH@@", auth)
|
|
205
|
+
else:
|
|
206
|
+
progress = ""
|
|
207
|
+
output = "printf '%s\\n' \"$BUNDLE\"\n"
|
|
208
|
+
return (
|
|
209
|
+
_SCRIPT_TEMPLATE
|
|
210
|
+
.replace("@@TARGET@@", _sh_squote(target or "uploaded"))
|
|
211
|
+
.replace("@@RUNS@@", runs)
|
|
212
|
+
.replace("@@PROGRESS@@", progress)
|
|
213
|
+
.replace("@@OUTPUT@@", output)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def scan_from_bundle(bundle: dict[str, Any]):
|
|
218
|
+
"""Run the full check catalog against a captured command bundle.
|
|
219
|
+
|
|
220
|
+
Returns the same `engine.ScanReport` a live SSH scan would — the bundle
|
|
221
|
+
simply stands in for the live runner. ``bundle["target"]`` labels the report.
|
|
222
|
+
"""
|
|
223
|
+
from umbermark.checks import ALL_CHECKS
|
|
224
|
+
from umbermark.core.engine import run_scan
|
|
225
|
+
|
|
226
|
+
runner = build_replay_runner(bundle)
|
|
227
|
+
target = bundle.get("target") or "uploaded"
|
|
228
|
+
return run_scan(target, runner, ALL_CHECKS)
|
umbermark/core/check.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Check abstractions — base class, Finding dataclass, severity/verdict enums."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from umbermark.core.ssh import CommandRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(Enum):
|
|
14
|
+
CRITICAL = "critical"
|
|
15
|
+
HIGH = "high"
|
|
16
|
+
MEDIUM = "medium"
|
|
17
|
+
LOW = "low"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Verdict(Enum):
|
|
21
|
+
PASS = "pass"
|
|
22
|
+
WARN = "warn"
|
|
23
|
+
FAIL = "fail"
|
|
24
|
+
SKIP = "skip"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Finding:
|
|
29
|
+
check_id: str
|
|
30
|
+
title: str
|
|
31
|
+
category: str
|
|
32
|
+
severity: Severity
|
|
33
|
+
weight: int
|
|
34
|
+
verdict: Verdict
|
|
35
|
+
evidence: str = ""
|
|
36
|
+
remediation: str = ""
|
|
37
|
+
skip_reason: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Check(ABC):
|
|
41
|
+
id: str = ""
|
|
42
|
+
title: str = ""
|
|
43
|
+
category: str = ""
|
|
44
|
+
severity: Severity = Severity.MEDIUM
|
|
45
|
+
weight: int = 5
|
|
46
|
+
remediation: str = ""
|
|
47
|
+
# Distro families this check applies to. Empty tuple means universal.
|
|
48
|
+
# Values are 'debian' | 'rhel' | etc, matching PlatformFacts.distro_family.
|
|
49
|
+
applicable_distros: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def run(self, runner: "CommandRunner") -> Finding:
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def applies_to(self, distro_family: str) -> bool:
|
|
56
|
+
if not self.applicable_distros:
|
|
57
|
+
return True
|
|
58
|
+
return distro_family in self.applicable_distros
|
|
59
|
+
|
|
60
|
+
def _finding(
|
|
61
|
+
self,
|
|
62
|
+
verdict: Verdict,
|
|
63
|
+
evidence: str = "",
|
|
64
|
+
skip_reason: str = "",
|
|
65
|
+
severity: Severity | None = None,
|
|
66
|
+
weight: int | None = None,
|
|
67
|
+
remediation: str | None = None,
|
|
68
|
+
) -> Finding:
|
|
69
|
+
"""Build a Finding. ``severity`` / ``weight`` / ``remediation`` override the
|
|
70
|
+
class defaults for this finding only (exposure-aware checks scale them)."""
|
|
71
|
+
rem = self.remediation if remediation is None else remediation
|
|
72
|
+
return Finding(
|
|
73
|
+
check_id=self.id,
|
|
74
|
+
title=self.title,
|
|
75
|
+
category=self.category,
|
|
76
|
+
severity=self.severity if severity is None else severity,
|
|
77
|
+
weight=self.weight if weight is None else weight,
|
|
78
|
+
verdict=verdict,
|
|
79
|
+
evidence=evidence,
|
|
80
|
+
remediation=rem if verdict in (Verdict.FAIL, Verdict.WARN) else "",
|
|
81
|
+
skip_reason=skip_reason,
|
|
82
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Scheduled check-ins for run-and-upload hosts (UMBR-66).
|
|
2
|
+
|
|
3
|
+
A check-in server is a saved server with no SSH key. A cron job on the host runs the same read-only collector as
|
|
4
|
+
the one-off upload and posts the bundle to POST /api/checkin with a per-server push token. Umbermark never connects
|
|
5
|
+
to the host. If no check-in arrives within the expected gap plus GRACE, the owner is alerted once per gap.
|
|
6
|
+
|
|
7
|
+
Token: `umk_ci_` + 32 random bytes (urlsafe). Only its SHA-256 is stored; it is shown once when the server is added
|
|
8
|
+
or the token is rotated. On the host it lives in a root-only header file read by curl (`-H @file`), so it never
|
|
9
|
+
appears in a process list.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import hashlib
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
17
|
+
TOKEN_PREFIX = "umk_ci_"
|
|
18
|
+
HEADER_FILE = "/etc/umbermark/checkin.header"
|
|
19
|
+
SCRIPT_PATH = "/usr/local/sbin/umbermark-checkin"
|
|
20
|
+
CRON_FILE = "/etc/cron.d/umbermark-checkin"
|
|
21
|
+
GRACE = dt.timedelta(hours=6)
|
|
22
|
+
# A check-in arriving sooner than this share of the plan's minimum re-scan gap is refused (429), so a check-in
|
|
23
|
+
# cannot be used to re-scan faster than the plan allows. 0.8 leaves room for cron jitter and clock drift.
|
|
24
|
+
MIN_GAP_SHARE = 0.8
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_token() -> tuple[str, str]:
|
|
28
|
+
"""(plaintext token, sha256 hex) for a new check-in server or a rotation."""
|
|
29
|
+
t = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
30
|
+
return t, hash_token(t)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hash_token(token: str) -> str:
|
|
34
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse(iso: str | None) -> dt.datetime | None:
|
|
38
|
+
if not iso:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
v = dt.datetime.fromisoformat(iso)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
return v if v.tzinfo else v.replace(tzinfo=dt.UTC)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def due_at(created_at: str, last_checkin_at: str | None, days: int) -> dt.datetime:
|
|
48
|
+
"""When the next check-in is expected (late after this plus GRACE)."""
|
|
49
|
+
base = _parse(last_checkin_at) or _parse(created_at) or dt.datetime.now(dt.UTC)
|
|
50
|
+
return base + dt.timedelta(days=days)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_missed(created_at: str, last_checkin_at: str | None, missed_alert_at: str | None, days: int,
|
|
54
|
+
now: dt.datetime | None = None) -> bool:
|
|
55
|
+
"""True when the check-in is overdue by more than GRACE and no alert went out for this gap yet."""
|
|
56
|
+
now = now or dt.datetime.now(dt.UTC)
|
|
57
|
+
if now <= due_at(created_at, last_checkin_at, days) + GRACE:
|
|
58
|
+
return False
|
|
59
|
+
sent = _parse(missed_alert_at)
|
|
60
|
+
last = _parse(last_checkin_at) or _parse(created_at)
|
|
61
|
+
return sent is None or (last is not None and sent < last)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def too_soon(last_checkin_at: str | None, min_rescan_days: int | None, now: dt.datetime | None = None) -> int:
|
|
65
|
+
"""Seconds the caller must wait before the next check-in is accepted (0 = accept now)."""
|
|
66
|
+
last = _parse(last_checkin_at)
|
|
67
|
+
if last is None or not min_rescan_days:
|
|
68
|
+
return 0
|
|
69
|
+
now = now or dt.datetime.now(dt.UTC)
|
|
70
|
+
ready = last + dt.timedelta(days=min_rescan_days * MIN_GAP_SHARE)
|
|
71
|
+
return max(0, int((ready - now).total_seconds()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cron_line(days: int, minute: int, hour: int) -> str:
|
|
75
|
+
"""The /etc/cron.d line for the interval: daily, weekly (Mondays) or monthly (the 1st)."""
|
|
76
|
+
when = {1: f"{minute} {hour} * * *", 7: f"{minute} {hour} * * 1", 30: f"{minute} {hour} 1 * *"}[days]
|
|
77
|
+
return f"{when} root {SCRIPT_PATH} >/dev/null 2>&1"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def setup_commands(base_url: str, token: str, days: int, seed: int) -> str:
|
|
81
|
+
"""Copy-paste setup for the host (run as a sudo-capable user). `seed` spreads cron times across servers."""
|
|
82
|
+
minute, hour = seed % 60, (seed // 60) % 24
|
|
83
|
+
return "\n".join([
|
|
84
|
+
f"sudo install -d -m 700 {HEADER_FILE.rsplit('/', 1)[0]}",
|
|
85
|
+
f"echo 'Authorization: Bearer {token}' | sudo tee {HEADER_FILE} >/dev/null && sudo chmod 600 {HEADER_FILE}",
|
|
86
|
+
f"sudo curl -fsSo {SCRIPT_PATH} {base_url}/scan/checkin-script && sudo chmod 700 {SCRIPT_PATH}",
|
|
87
|
+
f"sudo {SCRIPT_PATH} # first check-in now; read the script first if you like: less {SCRIPT_PATH}",
|
|
88
|
+
f"echo '{cron_line(days, minute, hour)}' | sudo tee {CRON_FILE} >/dev/null",
|
|
89
|
+
])
|
umbermark/core/crypto.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Indexed, non-secret peppered SHA-256 hashes.
|
|
2
|
+
|
|
3
|
+
Used for `abuse_log.ip_hash` and `target_host_hash` — deterministic enough to
|
|
4
|
+
index for rate-limit lookups, but not reversible to the source value without
|
|
5
|
+
the operator-only pepper. Not for password storage (use argon2 there).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def hash_with_pepper(value: str, pepper: str) -> str:
|
|
13
|
+
if not pepper:
|
|
14
|
+
raise ValueError("pepper is required and must be non-empty")
|
|
15
|
+
return hashlib.sha256(f"{pepper}::{value}".encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Human-readable link lifetime, derived from ANONYMOUS_REPORT_TTL_SECONDS.
|
|
2
|
+
|
|
3
|
+
User-facing copy must follow the configured TTL, not a hard-coded number.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from umbermark.core.settings import (
|
|
8
|
+
DEFAULT_ANONYMOUS_REPORT_TTL_SECONDS,
|
|
9
|
+
DEFAULT_SESSION_TTL_SECONDS,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def humanize_ttl(seconds: int, adjective: bool = False) -> str:
|
|
14
|
+
"""Render a TTL in whole days when it divides evenly, else hours/minutes.
|
|
15
|
+
|
|
16
|
+
2592000 -> "30 days", 86400 -> "1 day", 3600 -> "1 hour".
|
|
17
|
+
With adjective=True the unit is hyphenated and singular, for use before a
|
|
18
|
+
noun: 2592000 -> "30-day" ("a 30-day shareable URL").
|
|
19
|
+
"""
|
|
20
|
+
seconds = int(seconds)
|
|
21
|
+
for unit_seconds, name in ((86_400, "day"), (3_600, "hour"), (60, "minute")):
|
|
22
|
+
if seconds >= unit_seconds and seconds % unit_seconds == 0:
|
|
23
|
+
n = seconds // unit_seconds
|
|
24
|
+
if adjective:
|
|
25
|
+
return f"{n}-{name}"
|
|
26
|
+
return f"{n} {name}{'' if n == 1 else 's'}"
|
|
27
|
+
if adjective:
|
|
28
|
+
return f"{seconds}-second"
|
|
29
|
+
return f"{seconds} second{'' if seconds == 1 else 's'}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def link_lifetime(settings, adjective: bool = False) -> str:
|
|
33
|
+
"""Lifetime text for the app's settings (30 days when settings are absent)."""
|
|
34
|
+
ttl = getattr(settings, "anonymous_report_ttl_seconds", None)
|
|
35
|
+
if ttl is None:
|
|
36
|
+
ttl = DEFAULT_ANONYMOUS_REPORT_TTL_SECONDS
|
|
37
|
+
return humanize_ttl(ttl, adjective=adjective)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def session_lifetime(settings) -> str:
|
|
41
|
+
"""Sign-in session lifetime text (30 days when settings are absent)."""
|
|
42
|
+
ttl = getattr(settings, "session_ttl_seconds", None)
|
|
43
|
+
if ttl is None:
|
|
44
|
+
ttl = DEFAULT_SESSION_TTL_SECONDS
|
|
45
|
+
return humanize_ttl(ttl)
|
umbermark/core/engine.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Scan orchestrator — detects platform, runs checks, catches errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from umbermark.core.check import Check, Finding, Verdict
|
|
9
|
+
from umbermark.core.platform import PlatformFacts, detect_platform
|
|
10
|
+
from umbermark.core.scoring import ScoreResult, score
|
|
11
|
+
from umbermark.core.ssh import CommandRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ScanReport:
|
|
16
|
+
target: str
|
|
17
|
+
started_at: str
|
|
18
|
+
finished_at: str
|
|
19
|
+
findings: list[Finding]
|
|
20
|
+
result: ScoreResult
|
|
21
|
+
platform_facts: PlatformFacts = field(default_factory=PlatformFacts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_scan(
|
|
25
|
+
target: str,
|
|
26
|
+
runner: CommandRunner,
|
|
27
|
+
checks: Iterable[Check],
|
|
28
|
+
detect: bool = True,
|
|
29
|
+
) -> ScanReport:
|
|
30
|
+
started = datetime.now(timezone.utc).isoformat()
|
|
31
|
+
platform_facts = detect_platform(runner) if detect else PlatformFacts()
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
for c in checks:
|
|
34
|
+
# Skip checks that don't apply to this distro family — but only if
|
|
35
|
+
# we successfully detected one. Unknown family means "best effort".
|
|
36
|
+
if (
|
|
37
|
+
platform_facts.distro_family != "unknown"
|
|
38
|
+
and not c.applies_to(platform_facts.distro_family)
|
|
39
|
+
):
|
|
40
|
+
findings.append(
|
|
41
|
+
Finding(
|
|
42
|
+
check_id=c.id,
|
|
43
|
+
title=c.title,
|
|
44
|
+
category=c.category,
|
|
45
|
+
severity=c.severity,
|
|
46
|
+
weight=c.weight,
|
|
47
|
+
verdict=Verdict.SKIP,
|
|
48
|
+
skip_reason=f"check not applicable on distro_family={platform_facts.distro_family}",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
findings.append(c.run(runner))
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding(
|
|
57
|
+
check_id=c.id,
|
|
58
|
+
title=c.title,
|
|
59
|
+
category=c.category,
|
|
60
|
+
severity=c.severity,
|
|
61
|
+
weight=c.weight,
|
|
62
|
+
verdict=Verdict.SKIP,
|
|
63
|
+
skip_reason=f"check raised: {exc!r}",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
finished = datetime.now(timezone.utc).isoformat()
|
|
67
|
+
return ScanReport(
|
|
68
|
+
target=target,
|
|
69
|
+
started_at=started,
|
|
70
|
+
finished_at=finished,
|
|
71
|
+
findings=findings,
|
|
72
|
+
result=score(findings),
|
|
73
|
+
platform_facts=platform_facts,
|
|
74
|
+
)
|