umbermark-cli 1.0.0__tar.gz

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 (102) hide show
  1. umbermark_cli-1.0.0/PKG-INFO +11 -0
  2. umbermark_cli-1.0.0/README.md +284 -0
  3. umbermark_cli-1.0.0/pyproject.toml +26 -0
  4. umbermark_cli-1.0.0/setup.cfg +4 -0
  5. umbermark_cli-1.0.0/src/umbermark/__init__.py +18 -0
  6. umbermark_cli-1.0.0/src/umbermark/checks/__init__.py +88 -0
  7. umbermark_cli-1.0.0/src/umbermark/checks/auto_updates_configured.py +149 -0
  8. umbermark_cli-1.0.0/src/umbermark/checks/dns_resolver_sanity.py +47 -0
  9. umbermark_cli-1.0.0/src/umbermark/checks/fail2ban_enabled.py +67 -0
  10. umbermark_cli-1.0.0/src/umbermark/checks/failed_login_anomaly.py +48 -0
  11. umbermark_cli-1.0.0/src/umbermark/checks/failed_systemd_units.py +41 -0
  12. umbermark_cli-1.0.0/src/umbermark/checks/firewall_protective.py +247 -0
  13. umbermark_cli-1.0.0/src/umbermark/checks/journald_persistence.py +55 -0
  14. umbermark_cli-1.0.0/src/umbermark/checks/kernel_age.py +74 -0
  15. umbermark_cli-1.0.0/src/umbermark/checks/listen_vs_firewall_delta.py +136 -0
  16. umbermark_cli-1.0.0/src/umbermark/checks/mac_framework_enforcing.py +97 -0
  17. umbermark_cli-1.0.0/src/umbermark/checks/pending_security_updates.py +188 -0
  18. umbermark_cli-1.0.0/src/umbermark/checks/public_listen_anomalies.py +200 -0
  19. umbermark_cli-1.0.0/src/umbermark/checks/reboot_pending.py +71 -0
  20. umbermark_cli-1.0.0/src/umbermark/checks/repo_gpg_trust.py +97 -0
  21. umbermark_cli-1.0.0/src/umbermark/checks/root_disk_free.py +46 -0
  22. umbermark_cli-1.0.0/src/umbermark/checks/ssh_non_default_port.py +62 -0
  23. umbermark_cli-1.0.0/src/umbermark/checks/ssh_password_auth.py +71 -0
  24. umbermark_cli-1.0.0/src/umbermark/checks/ssh_permit_root.py +59 -0
  25. umbermark_cli-1.0.0/src/umbermark/checks/ssh_weak_ciphers.py +85 -0
  26. umbermark_cli-1.0.0/src/umbermark/checks/sudo_requires_authentication.py +41 -0
  27. umbermark_cli-1.0.0/src/umbermark/checks/sudoers_nopasswd.py +111 -0
  28. umbermark_cli-1.0.0/src/umbermark/checks/swap_pressure.py +51 -0
  29. umbermark_cli-1.0.0/src/umbermark/checks/time_sync.py +55 -0
  30. umbermark_cli-1.0.0/src/umbermark/checks/tmp_hardening.py +45 -0
  31. umbermark_cli-1.0.0/src/umbermark/checks/user_password_aging.py +95 -0
  32. umbermark_cli-1.0.0/src/umbermark/checks/world_writable_suid_audit.py +171 -0
  33. umbermark_cli-1.0.0/src/umbermark/cli/__init__.py +4 -0
  34. umbermark_cli-1.0.0/src/umbermark/cli/app.py +23 -0
  35. umbermark_cli-1.0.0/src/umbermark/cli/auth.py +44 -0
  36. umbermark_cli-1.0.0/src/umbermark/cli/commands/__init__.py +0 -0
  37. umbermark_cli-1.0.0/src/umbermark/cli/commands/doctor.py +16 -0
  38. umbermark_cli-1.0.0/src/umbermark/cli/commands/login.py +80 -0
  39. umbermark_cli-1.0.0/src/umbermark/cli/commands/push.py +54 -0
  40. umbermark_cli-1.0.0/src/umbermark/cli/commands/report.py +26 -0
  41. umbermark_cli-1.0.0/src/umbermark/cli/commands/scan.py +77 -0
  42. umbermark_cli-1.0.0/src/umbermark/cli/commands/scan_local.py +72 -0
  43. umbermark_cli-1.0.0/src/umbermark/cli/commands/scan_wsl.py +35 -0
  44. umbermark_cli-1.0.0/src/umbermark/cli/commands/version.py +11 -0
  45. umbermark_cli-1.0.0/src/umbermark/cli/config.py +14 -0
  46. umbermark_cli-1.0.0/src/umbermark/core/__init__.py +0 -0
  47. umbermark_cli-1.0.0/src/umbermark/core/accepted.py +61 -0
  48. umbermark_cli-1.0.0/src/umbermark/core/access.py +896 -0
  49. umbermark_cli-1.0.0/src/umbermark/core/branding.py +71 -0
  50. umbermark_cli-1.0.0/src/umbermark/core/capture.py +228 -0
  51. umbermark_cli-1.0.0/src/umbermark/core/check.py +82 -0
  52. umbermark_cli-1.0.0/src/umbermark/core/checkin.py +89 -0
  53. umbermark_cli-1.0.0/src/umbermark/core/crypto.py +15 -0
  54. umbermark_cli-1.0.0/src/umbermark/core/duration.py +45 -0
  55. umbermark_cli-1.0.0/src/umbermark/core/engine.py +74 -0
  56. umbermark_cli-1.0.0/src/umbermark/core/evidence.py +83 -0
  57. umbermark_cli-1.0.0/src/umbermark/core/external/__init__.py +6 -0
  58. umbermark_cli-1.0.0/src/umbermark/core/external/check.py +54 -0
  59. umbermark_cli-1.0.0/src/umbermark/core/external/checks/__init__.py +31 -0
  60. umbermark_cli-1.0.0/src/umbermark/core/external/checks/exposed_datastores.py +55 -0
  61. umbermark_cli-1.0.0/src/umbermark/core/external/checks/http_security_headers.py +249 -0
  62. umbermark_cli-1.0.0/src/umbermark/core/external/checks/legacy_services.py +52 -0
  63. umbermark_cli-1.0.0/src/umbermark/core/external/checks/open_management_ports.py +65 -0
  64. umbermark_cli-1.0.0/src/umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  65. umbermark_cli-1.0.0/src/umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  66. umbermark_cli-1.0.0/src/umbermark/core/external/checks/tls_hygiene.py +107 -0
  67. umbermark_cli-1.0.0/src/umbermark/core/external/engine.py +89 -0
  68. umbermark_cli-1.0.0/src/umbermark/core/external/ownership.py +607 -0
  69. umbermark_cli-1.0.0/src/umbermark/core/external/probe.py +103 -0
  70. umbermark_cli-1.0.0/src/umbermark/core/external/socket_probe.py +400 -0
  71. umbermark_cli-1.0.0/src/umbermark/core/firewall.py +713 -0
  72. umbermark_cli-1.0.0/src/umbermark/core/fleet.py +92 -0
  73. umbermark_cli-1.0.0/src/umbermark/core/history.py +235 -0
  74. umbermark_cli-1.0.0/src/umbermark/core/html_report.py +308 -0
  75. umbermark_cli-1.0.0/src/umbermark/core/keyenc.py +42 -0
  76. umbermark_cli-1.0.0/src/umbermark/core/labels.py +29 -0
  77. umbermark_cli-1.0.0/src/umbermark/core/lifecycle.py +100 -0
  78. umbermark_cli-1.0.0/src/umbermark/core/logredact.py +55 -0
  79. umbermark_cli-1.0.0/src/umbermark/core/partial.py +57 -0
  80. umbermark_cli-1.0.0/src/umbermark/core/pdf/__init__.py +0 -0
  81. umbermark_cli-1.0.0/src/umbermark/core/pdf/renderer.py +26 -0
  82. umbermark_cli-1.0.0/src/umbermark/core/pdf/weasyprint_renderer.py +18 -0
  83. umbermark_cli-1.0.0/src/umbermark/core/platform.py +73 -0
  84. umbermark_cli-1.0.0/src/umbermark/core/redact.py +75 -0
  85. umbermark_cli-1.0.0/src/umbermark/core/remediation.py +121 -0
  86. umbermark_cli-1.0.0/src/umbermark/core/report.py +25 -0
  87. umbermark_cli-1.0.0/src/umbermark/core/scoring.py +105 -0
  88. umbermark_cli-1.0.0/src/umbermark/core/settings.py +238 -0
  89. umbermark_cli-1.0.0/src/umbermark/core/short_id.py +27 -0
  90. umbermark_cli-1.0.0/src/umbermark/core/signing.py +24 -0
  91. umbermark_cli-1.0.0/src/umbermark/core/ssh.py +156 -0
  92. umbermark_cli-1.0.0/src/umbermark/core/ssh_fingerprint.py +16 -0
  93. umbermark_cli-1.0.0/src/umbermark/core/tiers.py +138 -0
  94. umbermark_cli-1.0.0/src/umbermark/core/webhooks.py +177 -0
  95. umbermark_cli-1.0.0/src/umbermark/core/wording.py +371 -0
  96. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/PKG-INFO +11 -0
  97. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/SOURCES.txt +100 -0
  98. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/dependency_links.txt +1 -0
  99. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/entry_points.txt +2 -0
  100. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/requires.txt +6 -0
  101. umbermark_cli-1.0.0/src/umbermark_cli.egg-info/top_level.txt +1 -0
  102. umbermark_cli-1.0.0/tests/test_product_tokens_drift.py +16 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: umbermark-cli
3
+ Version: 1.0.0
4
+ Summary: Umbermark — Linux server hardening scanner (CLI only).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: paramiko>=3.4
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pynacl>=1.5
11
+ Requires-Dist: itsdangerous>=2.2
@@ -0,0 +1,284 @@
1
+ <div align="center">
2
+
3
+ # 🛡️ Umbermark
4
+
5
+ ### Get a **0–100 hardening score** for any Linux server in 60 seconds.
6
+
7
+ *A scored, evidenced, shareable hardening report — read it, share it, watch the score climb.*
8
+
9
+ <br/>
10
+
11
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=white)
12
+ ![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white)
13
+ ![Redis](https://img.shields.io/badge/Redis%20%2B%20RQ-DC382D?style=for-the-badge&logo=redis&logoColor=white)
14
+ ![SQLite](https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white)
15
+ ![Docker](https://img.shields.io/badge/Docker%20Compose-2496ED?style=for-the-badge&logo=docker&logoColor=white)
16
+ ![Caddy](https://img.shields.io/badge/Caddy-1F88C0?style=for-the-badge&logo=caddy&logoColor=white)
17
+
18
+ ![Checks](https://img.shields.io/badge/checks-26-704428?style=flat-square)
19
+ ![Tests](https://img.shields.io/badge/tests-600%2B%20passing-2e7d32?style=flat-square)
20
+ ![Trust tiers](https://img.shields.io/badge/scan%20modes-3-00bcd4?style=flat-square)
21
+ ![Status](https://img.shields.io/badge/status-live-3fb950?style=flat-square)
22
+ ![Repo](https://img.shields.io/badge/repo-private-555?style=flat-square)
23
+
24
+ <br/>
25
+
26
+ [![Overview](https://img.shields.io/badge/▾_What_it_is-2a2a2a?style=for-the-badge)](#what)
27
+ [![Trust ladder](https://img.shields.io/badge/▾_Trust_ladder-2a2a2a?style=for-the-badge)](#tiers)
28
+ [![Scoring](https://img.shields.io/badge/▾_Scoring-2a2a2a?style=for-the-badge)](#scoring)
29
+ [![Checks](https://img.shields.io/badge/▾_Checks-2a2a2a?style=for-the-badge)](#checks)
30
+ [![Architecture](https://img.shields.io/badge/▾_Architecture-2a2a2a?style=for-the-badge)](#arch)
31
+ [![Quickstart](https://img.shields.io/badge/▾_Quickstart-2a2a2a?style=for-the-badge)](#quickstart)
32
+ [![Status](https://img.shields.io/badge/▾_Status-2a2a2a?style=for-the-badge)](#status)
33
+
34
+ </div>
35
+
36
+ ---
37
+
38
+ <a id="what"></a>
39
+
40
+ ## 🔍 What it is
41
+
42
+ Umbermark scans a Linux server, runs **26 weighted hardening checks**, and returns a **0–100 score** with a finding for every check — each one **named, evidenced, and remediated**. The scrubbed, shareable report at `/r/<id>` *is* the product: drop it in a PR or Slack, watch the number climb as you fix things.
43
+
44
+ **📈 Score history & before/after.** Saved servers keep every scan: a per-server score-over-time chart, the change since the previous and the first scan, and a before/after diff that lists which checks you fixed or regressed.
45
+ Owner-only (never on the shared link) and included with Solo, Pro and Agency.
46
+
47
+ > [!NOTE]
48
+ > The point isn't a wall of CVEs. It's the **"oh, *that's* exposed?"** moment — one scan surfaces something alarming on most real servers, with a copy-pasteable fix beside it.
49
+
50
+ **Who it's for:** *"I just inherited this box, what's wrong with it?"* · prosumer self-hosters on Hetzner / OVH / DigitalOcean · small MSPs onboarding client servers.
51
+
52
+ **Supported targets:** Debian / Ubuntu family (Debian 12, Ubuntu 22.04 & 24.04) and the RHEL family (RHEL / Rocky / Alma / Fedora), auto-detected via `/etc/os-release`.
53
+
54
+ ---
55
+
56
+ <a id="tiers"></a>
57
+
58
+ ## 🪜 The trust ladder — three ways to scan
59
+
60
+ You decide how much access to give. **The on-box options return the *same* 26-check report** — the only difference is whether Umbermark ever touches your server.
61
+
62
+ ```mermaid
63
+ flowchart LR
64
+ C["🖥️ Your Linux server"]
65
+ R["📊 0-100 score<br/>+ evidenced report"]
66
+ C -->|"① External · hostname only · no access"| R
67
+ C -->|"② Run and upload · run an auditable<br/>read-only script, upload the output"| R
68
+ C -->|"③ Managed SSH · read-only key,<br/>we connect from our servers"| R
69
+ ```
70
+
71
+ | # | Mode | Client grants | We get | Depth | Status |
72
+ |:-:|------|---------------|--------|-------|:------:|
73
+ | **1** | **External** | a hostname / IP | public attack surface (open ports, exposed services, TLS + SSH algorithms offered, HTTP headers) | 7 external checks | ✅ live |
74
+ | **2** | **Run & upload** | *nothing* — runs an auditable read-only script, uploads the output | the **data**, never access | full 26 checks | ✅ live |
75
+ | **3** | **Managed SSH** | a read-only SSH key | we connect & run it | full 26 checks | ✅ live |
76
+
77
+ > [!TIP]
78
+ > **Tier 2 is the "full confidence" path.** The collector script is small, plain, read-only code you `eval` before running — it changes nothing and (optionally) auto-uploads. You hand over **data, never access**, and get the identical report a managed scan produces. Generated *from the live check catalog*, so it can never drift.
79
+
80
+ ---
81
+
82
+ <a id="scoring"></a>
83
+
84
+ ## 🎯 How scoring works
85
+
86
+ Opinionated, transparent, and evidence-backed — the 20-years-of-sysadmin judgment is encoded in the **weights**.
87
+
88
+ - Every check returns a verdict: `pass` · `warn` · `fail` · `skip` (not applicable / couldn't determine).
89
+ - Score starts at **100**. Each `fail` subtracts its weight; each `warn` subtracts half. Clamped to `[0, 100]`.
90
+ - **Coverage %** is shown alongside the number — the report never fakes certainty.
91
+
92
+ > [!NOTE]
93
+ > **No feeds to keep fresh.** Every finding is derived from the target's *own* state at scan time — its configs, its package-manager metadata (`apt`/`dnf`), its exposed ports/TLS/SSH. There is **no CVE database, signature set, or threat-intel feed** on Umbermark's side, so there is nothing for an operator to update. Sharpening the catalog means editing the check code + weights and redeploying.
94
+
95
+ | Band | Score | |
96
+ |------|:-----:|--|
97
+ | 🟡 **gold** | 90–100 | hardened |
98
+ | 🟢 **green** | 70–89 | solid |
99
+ | 🟠 **yellow** | 40–69 | needs work |
100
+ | 🔴 **red** | 0–39 | exposed |
101
+ | ⚪ **incomplete** | — | coverage below threshold |
102
+
103
+ > [!IMPORTANT]
104
+ > **Outside-in scans of an unverified host show a partial score, not a band.** Only the passive checks run, so the report says `Outside-in score · partial` with the count (`3 of 7 checks ran`) and what verification unlocks. The full hardening score appears only for a verified host or an on-box scan, and a partial score is never plotted in score history.
105
+
106
+ ---
107
+
108
+ <a id="checks"></a>
109
+
110
+ ## 📋 The check catalog (26)
111
+
112
+ <details>
113
+ <summary><b>Identity &amp; Auth</b> — 9 checks</summary>
114
+
115
+ | Severity | Wt | Check |
116
+ |----------|:--:|-------|
117
+ | 🔴 critical | 18 | SSH root login disabled |
118
+ | 🔴 critical | 16 | SSH password authentication disabled (keys only) |
119
+ | 🟠 high | 12 | sudoers NOPASSWD audit |
120
+ | 🟠 high | 12 | sudo requires authentication (PAM) |
121
+ | 🟠 high | 10 | SSH disallows weak ciphers / MACs / key exchange |
122
+ | 🟠 high | 8 | fail2ban installed + enabled with active jails |
123
+ | 🟡 medium | 5 | User accounts have a password-aging policy |
124
+ | 🟡 medium | 4 | Failed SSH login attempts (last 7 days) |
125
+ | ⚪ low | 1 | SSH listening on a non-default port (informational) |
126
+
127
+ </details>
128
+
129
+ <details>
130
+ <summary><b>Patching &amp; Updates</b> — 5 checks</summary>
131
+
132
+ | Severity | Wt | Check |
133
+ |----------|:--:|-------|
134
+ | 🟠 high | 14 | Pending security updates |
135
+ | 🟠 high | 12 | Kernel age (running an up-to-date kernel) |
136
+ | 🟠 high | 10 | Reboot required (kernel / init update pending) |
137
+ | 🟡 medium | 6 | Automatic security updates configured |
138
+ | 🟡 medium | 5 | Package repos have signing keys configured |
139
+
140
+ </details>
141
+
142
+ <details>
143
+ <summary><b>Network &amp; Firewall</b> — 4 checks</summary>
144
+
145
+ | Severity | Wt | Check |
146
+ |----------|:--:|-------|
147
+ | 🔴 critical | 20 | Host firewall has protective input rules |
148
+ | 🟠 high | 12 | All public LISTEN sockets have a firewall allow rule |
149
+ | 🟡 medium | 5 | System clock is synced + NTP service is active |
150
+ | 🟡 medium | 3 | DNS resolver is configured and reachable |
151
+
152
+ </details>
153
+
154
+ <details>
155
+ <summary><b>Filesystem</b> — 3 checks</summary>
156
+
157
+ | Severity | Wt | Check |
158
+ |----------|:--:|-------|
159
+ | 🟠 high | 9 | World-writable files + non-standard SUID binaries audit |
160
+ | 🟠 high | 8 | Root disk has free space |
161
+ | 🟡 medium | 4 | /tmp mounted with noexec + nosuid |
162
+
163
+ </details>
164
+
165
+ <details>
166
+ <summary><b>Services / Process &amp; Hardening</b> — 5 checks</summary>
167
+
168
+ | Severity | Wt | Check |
169
+ |----------|:--:|-------|
170
+ | 🟠 high | 10 | Public-facing LISTEN sockets are on expected ports only |
171
+ | 🟠 high | 10 | Mandatory access control (AppArmor / SELinux) enforcing |
172
+ | 🟡 medium | 6 | No failed systemd units |
173
+ | 🟡 medium | 4 | journald configured for persistent storage |
174
+ | ⚪ low | 2 | No sustained swap pressure |
175
+
176
+ </details>
177
+
178
+ > Each check ships a one-paragraph *why it matters* and a copy-pasteable remediation snippet. The catalog is the single source of truth: the Tier-2 collector script is **generated from it**, so the two never diverge.
179
+
180
+ ---
181
+
182
+ <a id="arch"></a>
183
+
184
+ ## 🏗️ Architecture
185
+
186
+ ```mermaid
187
+ flowchart TD
188
+ B["🌐 Browser / CLI"] --> CADDY["Caddy<br/>TLS + reverse proxy"]
189
+ CADDY --> API["FastAPI app"]
190
+
191
+ API -->|"③ managed scan"| Q[("Redis + RQ")]
192
+ Q --> W["Worker"]
193
+ W -->|"paramiko SSH"| RUN["CommandRunner"]
194
+
195
+ API -->|"② run + upload"| CAP["capture: replay bundle"]
196
+ CAP --> RUN
197
+
198
+ RUN --> CHK["26 checks"]
199
+ CHK --> SCORE["weighted scoring → 0-100"]
200
+ SCORE --> DB[("SQLite")]
201
+ API --> DB
202
+
203
+ POLAR["Polar webhook"] -->|"Standard Webhooks sig"| API
204
+ DB -.->|"Litestream"| R2[("Cloudflare R2")]
205
+ ```
206
+
207
+ **The reusable core:** `run_scan(target, runner, checks)`. Every tier swaps only the *runner* — live `paramiko` (Tier 3), a replayed command bundle (Tier 2), and an outside-in network probe (Tier 1, via the parallel `run_external_scan`) — so all paths share one scoring model and one report.
208
+
209
+ <details>
210
+ <summary><b>Stack</b></summary>
211
+
212
+ | Layer | Choice |
213
+ |-------|--------|
214
+ | Language | Python 3.10+ |
215
+ | Web | FastAPI · Jinja2 templates |
216
+ | SSH | paramiko |
217
+ | Queue | Redis + RQ |
218
+ | Storage | SQLite (+ Litestream → Cloudflare R2) |
219
+ | Crypto | PyNaCl (SecretBox key encryption) |
220
+ | Billing | Polar.sh via Starhold Account · $5 one-shot + PDF · Solo $9 / Pro $19 / Agency $49 monthly |
221
+ | Captcha | Cloudflare Turnstile (not wired: `TURNSTILE_WIRED=0`) |
222
+ | CLI | Typer (`umbermark` ↔ SaaS sync, scheduler, score-drop alerts) |
223
+ | Deploy | Rootless Podman + systemd quadlet (prod) · Docker Compose (local dev) · Caddy |
224
+
225
+ </details>
226
+
227
+ ---
228
+
229
+ <a id="quickstart"></a>
230
+
231
+ ## 🚀 Quickstart (local dev)
232
+
233
+ > [!IMPORTANT]
234
+ > Local stack runs on WSL Docker; web binds to `localhost:8000` with `BASE_URL=http://localhost:8000` (plain http so session cookies aren't `Secure`-gated).
235
+
236
+ ```bash
237
+ # bring up redis + web + worker (dev overlay, no Caddy/TLS)
238
+ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d redis web worker
239
+ # → http://localhost:8000
240
+
241
+ # run the test suite (WSL Debian venv: pip install -e .)
242
+ pip install -e .
243
+ pytest -q
244
+ ```
245
+
246
+ <details>
247
+ <summary><b>CLI</b></summary>
248
+
249
+ > [!NOTE]
250
+ > The CLI is on PyPI as `umbermark-cli` (`pip install umbermark-cli`; it installs the `umbermark` command, and talks to `https://serverscanner.starhold.app` unless you pass `--api-url` or set `UMBERMARK_API_URL`). `umbermark` on PyPI is only a 0.0.0 name-reservation placeholder for the full server package, which is not published. To work on the code, run from a checkout (`pip install -e .`). The live site never tells visitors to `pip install` anything.
251
+
252
+ ```bash
253
+ umbermark doctor # environment check
254
+ umbermark login # magic-link loopback auth
255
+ umbermark scan <host> # scan a server from your machine
256
+ umbermark scan-local # scan localhost
257
+ umbermark push report.json # attach a report to your account
258
+ ```
259
+
260
+ </details>
261
+
262
+ ---
263
+
264
+ <a id="status"></a>
265
+
266
+ ## 📦 Status
267
+
268
+ > [!NOTE]
269
+ > **Live and open (since 2026-11-14).** Serving at [`serverscanner.starhold.app`](https://serverscanner.starhold.app) (canonical since 2026-11-14; `umbermark.starhold.app` keeps working as an alias, and its API, scan script, upload and check-in paths answer directly for older CLIs and installed scripts) (rootless Podman + systemd quadlet on the Starhold fleet, fronted by Caddy). All three scan tiers work end-to-end. Sign-in is open through **Starhold Account**; the $5 report and the Solo / Pro / Agency plans are on sale, with checkout handled by Starhold Account (Polar is the merchant of record).
270
+
271
+ - [x] 26-check catalog · weighted scoring · distro-aware engine
272
+ - [x] FastAPI backend · Starhold Account sign-in (`STARSHIP_ENABLED=1`, `SIGNIN_ENABLED=1`; magic links retired) · anonymous scan · scrubbed `/r/<id>` share view
273
+ - [x] Billing through Starhold Account · $5 one-shot · Solo $9 / Pro $19 / Agency $49 monthly (`MONTHLY_PLANS_ENABLED=1`, `CHECKOUT_PAUSED=0`) · PDF reports · 3-month retention · ownership key
274
+ - [x] Typer CLI ↔ SaaS sync · scheduler · score-drop alerts
275
+ - [x] Score history, sparkline and before/after diff per saved server (owner-only)
276
+ - [x] Abuse mitigations (rate limits, keyed-IP-hash audit log kept 1 year) · hourly retention purge
277
+ - [x] Dark **"Slate Vault"** web theme · 3-tier trust-ladder landing
278
+ - [x] **Tier 2 — run & upload** (auditable script → captured bundle → identical report)
279
+ - [x] **Tier 1 — external (outside-in) scanner** (7 credential-less checks · `POST /scan/external` live)
280
+ - [ ] PyPI publish of the CLI (operator account action, still open)
281
+
282
+ <div align="center">
283
+ <sub>© 2026 Umbermark · built with the help of <a href="https://claude.com/claude-code">Claude Code</a></sub>
284
+ </div>
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "umbermark-cli"
7
+ version = "1.0.0"
8
+ description = "Umbermark — Linux server hardening scanner (CLI only)."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "paramiko>=3.4",
12
+ "typer>=0.12",
13
+ "jinja2>=3.1",
14
+ # UMBR-60: imported on the CLI path (push, signing, key encryption); missing
15
+ # from the first slim build, so the wheel crashed on import in a clean venv.
16
+ "httpx>=0.27",
17
+ "pynacl>=1.5",
18
+ "itsdangerous>=2.2",
19
+ ]
20
+
21
+ [project.scripts]
22
+ umbermark = "umbermark.cli:app"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+ include = ["umbermark", "umbermark.core*", "umbermark.checks*", "umbermark.cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """Umbermark — Linux server hardening scanner."""
2
+ from importlib import metadata as _metadata
3
+
4
+ # The version is whatever is installed: the slim CLI wheel is "umbermark-cli", the full
5
+ # package is "umbermark". Fall back to a source checkout that is not pip-installed.
6
+ _FALLBACK_VERSION = "0.0.1"
7
+
8
+
9
+ def _installed_version() -> str:
10
+ for dist in ("umbermark-cli", "umbermark"):
11
+ try:
12
+ return _metadata.version(dist)
13
+ except _metadata.PackageNotFoundError:
14
+ continue
15
+ return _FALLBACK_VERSION
16
+
17
+
18
+ __version__ = _installed_version()
@@ -0,0 +1,88 @@
1
+ """Check registry — order is the display order in reports."""
2
+ from __future__ import annotations
3
+
4
+ from umbermark.checks.auto_updates_configured import AutoUpdatesConfiguredCheck
5
+ from umbermark.checks.dns_resolver_sanity import DnsResolverSanityCheck
6
+ from umbermark.checks.fail2ban_enabled import Fail2banEnabledCheck
7
+ from umbermark.checks.failed_login_anomaly import FailedLoginAnomalyCheck
8
+ from umbermark.checks.failed_systemd_units import FailedSystemdUnitsCheck
9
+ from umbermark.checks.firewall_protective import FirewallProtectiveCheck
10
+ from umbermark.checks.journald_persistence import JournaldPersistenceCheck
11
+ from umbermark.checks.kernel_age import KernelAgeCheck
12
+ from umbermark.checks.listen_vs_firewall_delta import ListenVsFirewallDeltaCheck
13
+ from umbermark.checks.mac_framework_enforcing import MacFrameworkEnforcingCheck
14
+ from umbermark.checks.pending_security_updates import PendingSecurityUpdatesCheck
15
+ from umbermark.checks.public_listen_anomalies import PublicListenAnomaliesCheck
16
+ from umbermark.checks.reboot_pending import RebootPendingCheck
17
+ from umbermark.checks.repo_gpg_trust import RepoGpgTrustCheck
18
+ from umbermark.checks.root_disk_free import RootDiskFreeCheck
19
+ from umbermark.checks.ssh_non_default_port import SSHNonDefaultPortCheck
20
+ from umbermark.checks.ssh_password_auth import SSHPasswordAuthCheck
21
+ from umbermark.checks.ssh_permit_root import SSHPermitRootLoginCheck
22
+ from umbermark.checks.ssh_weak_ciphers import SSHWeakCiphersCheck
23
+ from umbermark.checks.sudo_requires_authentication import SudoRequiresAuthenticationCheck
24
+ from umbermark.checks.sudoers_nopasswd import SudoersNopasswdCheck
25
+ from umbermark.checks.swap_pressure import SwapPressureCheck
26
+ from umbermark.checks.time_sync import TimeSyncCheck
27
+ from umbermark.checks.tmp_hardening import TmpHardeningCheck
28
+ from umbermark.checks.user_password_aging import UserPasswordAgingCheck
29
+ from umbermark.checks.world_writable_suid_audit import WorldWritableSuidAuditCheck
30
+
31
+ ALL_CHECKS = [
32
+ SSHPermitRootLoginCheck(),
33
+ SSHPasswordAuthCheck(),
34
+ SSHWeakCiphersCheck(),
35
+ SSHNonDefaultPortCheck(),
36
+ SudoersNopasswdCheck(),
37
+ SudoRequiresAuthenticationCheck(),
38
+ UserPasswordAgingCheck(),
39
+ FailedLoginAnomalyCheck(),
40
+ Fail2banEnabledCheck(),
41
+ KernelAgeCheck(),
42
+ PendingSecurityUpdatesCheck(),
43
+ RebootPendingCheck(),
44
+ AutoUpdatesConfiguredCheck(),
45
+ RepoGpgTrustCheck(),
46
+ FirewallProtectiveCheck(),
47
+ TimeSyncCheck(),
48
+ DnsResolverSanityCheck(),
49
+ RootDiskFreeCheck(),
50
+ TmpHardeningCheck(),
51
+ FailedSystemdUnitsCheck(),
52
+ SwapPressureCheck(),
53
+ JournaldPersistenceCheck(),
54
+ MacFrameworkEnforcingCheck(),
55
+ PublicListenAnomaliesCheck(),
56
+ ListenVsFirewallDeltaCheck(),
57
+ WorldWritableSuidAuditCheck(),
58
+ ]
59
+
60
+ __all__ = [
61
+ "ALL_CHECKS",
62
+ "SSHPermitRootLoginCheck",
63
+ "SSHPasswordAuthCheck",
64
+ "SSHWeakCiphersCheck",
65
+ "SSHNonDefaultPortCheck",
66
+ "SudoersNopasswdCheck",
67
+ "SudoRequiresAuthenticationCheck",
68
+ "UserPasswordAgingCheck",
69
+ "FailedLoginAnomalyCheck",
70
+ "Fail2banEnabledCheck",
71
+ "KernelAgeCheck",
72
+ "PendingSecurityUpdatesCheck",
73
+ "RebootPendingCheck",
74
+ "AutoUpdatesConfiguredCheck",
75
+ "RepoGpgTrustCheck",
76
+ "FirewallProtectiveCheck",
77
+ "TimeSyncCheck",
78
+ "DnsResolverSanityCheck",
79
+ "RootDiskFreeCheck",
80
+ "TmpHardeningCheck",
81
+ "FailedSystemdUnitsCheck",
82
+ "SwapPressureCheck",
83
+ "JournaldPersistenceCheck",
84
+ "MacFrameworkEnforcingCheck",
85
+ "PublicListenAnomaliesCheck",
86
+ "ListenVsFirewallDeltaCheck",
87
+ "WorldWritableSuidAuditCheck",
88
+ ]
@@ -0,0 +1,149 @@
1
+ """Automatic security updates configured (unattended-upgrades / dnf-automatic)."""
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
+ # apt.systemd.daily (apt 2.x) semantics, read from the script on real Ubuntu 24.04:
10
+ # Enable: numeric, 0 disables the whole job (absent = 1).
11
+ # Update-Package-Lists / Unattended-Upgrade: "always" or N days; check_stamp treats 0 as off.
12
+ _APT_KEY = r'APT::Periodic::{key}\s+"([^"]*)"'
13
+
14
+
15
+ def _apt_value(dump: str, key: str) -> str | None:
16
+ m = re.search(_APT_KEY.format(key=re.escape(key)), dump)
17
+ return m.group(1).strip() if m else None
18
+
19
+
20
+ def _interval_on(value: str | None) -> bool:
21
+ """Mirror apt.systemd.daily check_stamp, which compares CASE-SENSITIVELY as shell strings.
22
+
23
+ On: exactly "always", or digits with an optional lowercase s/m/h/d suffix. "Always", "1D", "12H"
24
+ never run in apt, so they are off here. Exactly "0" is off (`[ "$interval" = 0 ]` returns early);
25
+ "00" and "0d" are not that string, so apt proceeds and they run every time -> on."""
26
+ if value is None:
27
+ return False
28
+ v = value.strip()
29
+ if v == "always":
30
+ return True
31
+ if v == "0":
32
+ return False
33
+ return re.fullmatch(r"\d+[smhd]?", v) is not None
34
+
35
+
36
+ _TRUTHY = ("yes", "true", "1", "on")
37
+
38
+
39
+ def _dnf_applies_updates(conf: str) -> bool:
40
+ """dnf-automatic only *downloads* unless [commands] apply_updates is truthy (default: no).
41
+
42
+ Section-aware: only [commands] counts. The last apply_updates line there wins, with inline
43
+ ` #`/` ;` comments stripped; a value that is not a recognised yes is treated as NOT yes (never
44
+ skipped, so it cannot let an earlier `yes` win)."""
45
+ section = None
46
+ value = None
47
+ for line in conf.splitlines():
48
+ s = line.strip()
49
+ if not s or s[0] in "#;":
50
+ continue
51
+ m = re.fullmatch(r"\[([^\]]+)\]", s)
52
+ if m:
53
+ section = m.group(1).strip().lower()
54
+ continue
55
+ if section == "commands":
56
+ kv = re.match(r"apply_updates\s*[=:]\s*(.*)$", s, re.I)
57
+ if kv:
58
+ value = re.split(r"\s[#;]", kv.group(1), maxsplit=1)[0].strip().lower()
59
+ return value in _TRUTHY
60
+
61
+ class AutoUpdatesConfiguredCheck(Check):
62
+ id = "auto_updates_configured"
63
+ title = "Automatic security updates configured"
64
+ category = "patching"
65
+ severity = Severity.MEDIUM
66
+ weight = 6
67
+ applicable_distros = ("debian", "rhel")
68
+ remediation = (
69
+ "Enable automated security patching so you don't have to chase every CVE.\n"
70
+ "Debian/Ubuntu:\n"
71
+ " sudo apt install unattended-upgrades\n"
72
+ " sudo dpkg-reconfigure -plow unattended-upgrades\n"
73
+ "RHEL/Rocky:\n"
74
+ " sudo dnf install dnf-automatic\n"
75
+ " # dnf-automatic only DOWNLOADS by default; make it install:\n"
76
+ " sudo sed -i 's/^apply_updates *=.*/apply_updates = yes/' /etc/dnf/automatic.conf\n"
77
+ " sudo systemctl enable --now dnf-automatic.timer\n"
78
+ "Fedora 41+ (dnf5): sudo dnf install dnf5-plugin-automatic, set apply_updates = yes\n"
79
+ "under [commands] in /etc/dnf/automatic.conf, then\n"
80
+ " sudo systemctl enable --now dnf5-automatic.timer"
81
+ )
82
+
83
+ def run(self, runner: CommandRunner) -> Finding:
84
+ # Debian/Ubuntu stock images: unattended-upgrades runs from apt-daily-upgrade.timer ->
85
+ # apt.systemd.daily, which needs Enable != 0, Update-Package-Lists on (else it upgrades from
86
+ # stale lists) and Unattended-Upgrade on; plus the package. There is no
87
+ # unattended-upgrades.timer unit on them (verified on real Ubuntu 24.04 / Debian 12, UMBR-23).
88
+ timer = runner.run("systemctl is-enabled apt-daily-upgrade.timer 2>/dev/null", timeout=5)
89
+ lists_timer = runner.run("systemctl is-enabled apt-daily.timer 2>/dev/null", timeout=5)
90
+ dump = runner.run("apt-config dump APT::Periodic 2>/dev/null", timeout=5)
91
+ pkg = runner.run(
92
+ "dpkg-query -W -f='${db:Status-Abbrev}' unattended-upgrades 2>/dev/null", timeout=5
93
+ )
94
+ enable = _apt_value(dump.stdout, "Enable")
95
+ enable_on = enable is None or not (enable.isdigit() and int(enable) == 0)
96
+ if (
97
+ timer.ok and timer.stdout.strip() == "enabled"
98
+ # apt-daily.timer runs `apt.systemd.daily update` (refreshes lists); upgrade timer only installs.
99
+ and lists_timer.ok and lists_timer.stdout.strip() == "enabled"
100
+ and pkg.stdout.strip().startswith("ii")
101
+ and enable_on
102
+ and _interval_on(_apt_value(dump.stdout, "Update-Package-Lists"))
103
+ and _interval_on(_apt_value(dump.stdout, "Unattended-Upgrade"))
104
+ ):
105
+ return self._finding(
106
+ Verdict.PASS,
107
+ evidence=(
108
+ "apt-daily-upgrade.timer enabled, unattended-upgrades installed, "
109
+ "APT::Periodic Enable/Update-Package-Lists/Unattended-Upgrade all on"
110
+ ),
111
+ )
112
+ # Older/other layouts: a dedicated unattended-upgrades.timer
113
+ r = runner.run("systemctl is-enabled unattended-upgrades.timer 2>/dev/null", timeout=5)
114
+ if r.ok and r.stdout.strip() == "enabled":
115
+ return self._finding(
116
+ Verdict.PASS, evidence="unattended-upgrades.timer is enabled"
117
+ )
118
+ # RHEL: dnf-automatic.timer (dnf4) or dnf5-automatic.timer (dnf5: Fedora 41+, UMBR-33),
119
+ # but only counts if it APPLIES updates (both default to download only).
120
+ r2 = runner.run("systemctl is-enabled dnf-automatic.timer 2>/dev/null", timeout=5)
121
+ r5 = runner.run("systemctl is-enabled dnf5-automatic.timer 2>/dev/null", timeout=5)
122
+ conf = runner.run("cat /etc/dnf/automatic.conf 2>/dev/null", timeout=5)
123
+ # dnf5-automatic(8): defaults from this file, then /etc/dnf/automatic.conf applied on top.
124
+ # Absent on dnf4 hosts, so prepending it is a no-op there; last apply_updates wins.
125
+ dnf5_defaults = runner.run(
126
+ "cat /usr/share/dnf5/dnf5-plugins/automatic.conf 2>/dev/null", timeout=5
127
+ )
128
+ merged = (dnf5_defaults.stdout if dnf5_defaults.ok else "") + "\n" + conf.stdout
129
+ for unit, timer in (("dnf5-automatic.timer", r5), ("dnf-automatic.timer", r2)):
130
+ if not (timer.ok and timer.stdout.strip() == "enabled"):
131
+ continue
132
+ if _dnf_applies_updates(merged):
133
+ return self._finding(
134
+ Verdict.PASS, evidence=f"{unit} enabled with apply_updates = yes"
135
+ )
136
+ return self._finding(
137
+ Verdict.WARN,
138
+ evidence=(
139
+ f"{unit} is enabled but the automatic.conf in effect does not set "
140
+ "apply_updates = yes (default: only downloads, never installs)"
141
+ ),
142
+ )
143
+ return self._finding(
144
+ Verdict.WARN,
145
+ evidence=(
146
+ "no automatic updates enabled (apt periodic/unattended-upgrades, "
147
+ "unattended-upgrades.timer and dnf-automatic/dnf5-automatic timers all off or incomplete)"
148
+ ),
149
+ )
@@ -0,0 +1,47 @@
1
+ """DNS resolver sanity — resolv.conf populated + a known name resolves."""
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
+
8
+ class DnsResolverSanityCheck(Check):
9
+ id = "dns_resolver_sanity"
10
+ title = "DNS resolver is configured and reachable"
11
+ category = "network"
12
+ severity = Severity.MEDIUM
13
+ weight = 3
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Ensure DNS is configured:\n"
17
+ " cat /etc/resolv.conf # should have at least one 'nameserver <ip>' line\n"
18
+ "If managed by systemd-resolved, point /etc/resolv.conf at it:\n"
19
+ " sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf"
20
+ )
21
+
22
+ def run(self, runner: CommandRunner) -> Finding:
23
+ rr = runner.run("cat /etc/resolv.conf 2>/dev/null", timeout=5)
24
+ # Issue the lookup BEFORE any early return: the capture script is enumerated by running
25
+ # every check against an all-failing runner, so a command gated behind a successful
26
+ # resolv.conf read is never captured and replays as "lookup failed" (UMBR-23).
27
+ rg = runner.run("getent hosts deb.debian.org 2>/dev/null", timeout=8)
28
+ if not rr.ok or not rr.stdout:
29
+ return self._finding(
30
+ Verdict.SKIP, skip_reason="could not read /etc/resolv.conf"
31
+ )
32
+ nameservers = [
33
+ line for line in rr.stdout.splitlines() if line.strip().startswith("nameserver")
34
+ ]
35
+ if not nameservers:
36
+ return self._finding(
37
+ Verdict.FAIL, evidence="/etc/resolv.conf has 0 nameserver lines"
38
+ )
39
+ if rg.ok and rg.stdout.strip():
40
+ return self._finding(
41
+ Verdict.PASS,
42
+ evidence=f"{len(nameservers)} nameserver(s), test lookup succeeded",
43
+ )
44
+ return self._finding(
45
+ Verdict.WARN,
46
+ evidence=f"{len(nameservers)} nameserver(s) configured but test lookup failed",
47
+ )