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.
Files changed (96) hide show
  1. umbermark/__init__.py +18 -0
  2. umbermark/checks/__init__.py +88 -0
  3. umbermark/checks/auto_updates_configured.py +149 -0
  4. umbermark/checks/dns_resolver_sanity.py +47 -0
  5. umbermark/checks/fail2ban_enabled.py +67 -0
  6. umbermark/checks/failed_login_anomaly.py +48 -0
  7. umbermark/checks/failed_systemd_units.py +41 -0
  8. umbermark/checks/firewall_protective.py +247 -0
  9. umbermark/checks/journald_persistence.py +55 -0
  10. umbermark/checks/kernel_age.py +74 -0
  11. umbermark/checks/listen_vs_firewall_delta.py +136 -0
  12. umbermark/checks/mac_framework_enforcing.py +97 -0
  13. umbermark/checks/pending_security_updates.py +188 -0
  14. umbermark/checks/public_listen_anomalies.py +200 -0
  15. umbermark/checks/reboot_pending.py +71 -0
  16. umbermark/checks/repo_gpg_trust.py +97 -0
  17. umbermark/checks/root_disk_free.py +46 -0
  18. umbermark/checks/ssh_non_default_port.py +62 -0
  19. umbermark/checks/ssh_password_auth.py +71 -0
  20. umbermark/checks/ssh_permit_root.py +59 -0
  21. umbermark/checks/ssh_weak_ciphers.py +85 -0
  22. umbermark/checks/sudo_requires_authentication.py +41 -0
  23. umbermark/checks/sudoers_nopasswd.py +111 -0
  24. umbermark/checks/swap_pressure.py +51 -0
  25. umbermark/checks/time_sync.py +55 -0
  26. umbermark/checks/tmp_hardening.py +45 -0
  27. umbermark/checks/user_password_aging.py +95 -0
  28. umbermark/checks/world_writable_suid_audit.py +171 -0
  29. umbermark/cli/__init__.py +4 -0
  30. umbermark/cli/app.py +23 -0
  31. umbermark/cli/auth.py +44 -0
  32. umbermark/cli/commands/__init__.py +0 -0
  33. umbermark/cli/commands/doctor.py +16 -0
  34. umbermark/cli/commands/login.py +80 -0
  35. umbermark/cli/commands/push.py +54 -0
  36. umbermark/cli/commands/report.py +26 -0
  37. umbermark/cli/commands/scan.py +77 -0
  38. umbermark/cli/commands/scan_local.py +72 -0
  39. umbermark/cli/commands/scan_wsl.py +35 -0
  40. umbermark/cli/commands/version.py +11 -0
  41. umbermark/cli/config.py +14 -0
  42. umbermark/core/__init__.py +0 -0
  43. umbermark/core/accepted.py +61 -0
  44. umbermark/core/access.py +896 -0
  45. umbermark/core/branding.py +71 -0
  46. umbermark/core/capture.py +228 -0
  47. umbermark/core/check.py +82 -0
  48. umbermark/core/checkin.py +89 -0
  49. umbermark/core/crypto.py +15 -0
  50. umbermark/core/duration.py +45 -0
  51. umbermark/core/engine.py +74 -0
  52. umbermark/core/evidence.py +83 -0
  53. umbermark/core/external/__init__.py +6 -0
  54. umbermark/core/external/check.py +54 -0
  55. umbermark/core/external/checks/__init__.py +31 -0
  56. umbermark/core/external/checks/exposed_datastores.py +55 -0
  57. umbermark/core/external/checks/http_security_headers.py +249 -0
  58. umbermark/core/external/checks/legacy_services.py +52 -0
  59. umbermark/core/external/checks/open_management_ports.py +65 -0
  60. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  61. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  62. umbermark/core/external/checks/tls_hygiene.py +107 -0
  63. umbermark/core/external/engine.py +89 -0
  64. umbermark/core/external/ownership.py +607 -0
  65. umbermark/core/external/probe.py +103 -0
  66. umbermark/core/external/socket_probe.py +400 -0
  67. umbermark/core/firewall.py +713 -0
  68. umbermark/core/fleet.py +92 -0
  69. umbermark/core/history.py +235 -0
  70. umbermark/core/html_report.py +308 -0
  71. umbermark/core/keyenc.py +42 -0
  72. umbermark/core/labels.py +29 -0
  73. umbermark/core/lifecycle.py +100 -0
  74. umbermark/core/logredact.py +55 -0
  75. umbermark/core/partial.py +57 -0
  76. umbermark/core/pdf/__init__.py +0 -0
  77. umbermark/core/pdf/renderer.py +26 -0
  78. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  79. umbermark/core/platform.py +73 -0
  80. umbermark/core/redact.py +75 -0
  81. umbermark/core/remediation.py +121 -0
  82. umbermark/core/report.py +25 -0
  83. umbermark/core/scoring.py +105 -0
  84. umbermark/core/settings.py +238 -0
  85. umbermark/core/short_id.py +27 -0
  86. umbermark/core/signing.py +24 -0
  87. umbermark/core/ssh.py +156 -0
  88. umbermark/core/ssh_fingerprint.py +16 -0
  89. umbermark/core/tiers.py +138 -0
  90. umbermark/core/webhooks.py +177 -0
  91. umbermark/core/wording.py +371 -0
  92. umbermark_cli-1.0.0.dist-info/METADATA +11 -0
  93. umbermark_cli-1.0.0.dist-info/RECORD +96 -0
  94. umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
  95. umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
  96. 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)
@@ -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
+ ])
@@ -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)
@@ -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
+ )