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

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+

|
|
17
|
+
|
|
18
|
+

|
|
19
|
+

|
|
20
|
+

|
|
21
|
+

|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
<br/>
|
|
25
|
+
|
|
26
|
+
[](#what)
|
|
27
|
+
[](#tiers)
|
|
28
|
+
[](#scoring)
|
|
29
|
+
[](#checks)
|
|
30
|
+
[](#arch)
|
|
31
|
+
[](#quickstart)
|
|
32
|
+
[](#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 & 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 & 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 & 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 & 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 **not on PyPI yet** (`umbermark` there is a 0.0.0 name-reservation placeholder and `umbermark-cli` does not exist). Run it 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 [`umbermark.starhold.app`](https://umbermark.starhold.app) (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.0rc1"
|
|
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,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
|
+
)
|