umbermark-cli 1.0.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umbermark/__init__.py +3 -0
- umbermark/checks/__init__.py +88 -0
- umbermark/checks/auto_updates_configured.py +149 -0
- umbermark/checks/dns_resolver_sanity.py +47 -0
- umbermark/checks/fail2ban_enabled.py +67 -0
- umbermark/checks/failed_login_anomaly.py +48 -0
- umbermark/checks/failed_systemd_units.py +41 -0
- umbermark/checks/firewall_protective.py +247 -0
- umbermark/checks/journald_persistence.py +55 -0
- umbermark/checks/kernel_age.py +74 -0
- umbermark/checks/listen_vs_firewall_delta.py +136 -0
- umbermark/checks/mac_framework_enforcing.py +97 -0
- umbermark/checks/pending_security_updates.py +188 -0
- umbermark/checks/public_listen_anomalies.py +200 -0
- umbermark/checks/reboot_pending.py +71 -0
- umbermark/checks/repo_gpg_trust.py +97 -0
- umbermark/checks/root_disk_free.py +46 -0
- umbermark/checks/ssh_non_default_port.py +62 -0
- umbermark/checks/ssh_password_auth.py +71 -0
- umbermark/checks/ssh_permit_root.py +59 -0
- umbermark/checks/ssh_weak_ciphers.py +85 -0
- umbermark/checks/sudo_requires_authentication.py +41 -0
- umbermark/checks/sudoers_nopasswd.py +111 -0
- umbermark/checks/swap_pressure.py +51 -0
- umbermark/checks/time_sync.py +55 -0
- umbermark/checks/tmp_hardening.py +45 -0
- umbermark/checks/user_password_aging.py +95 -0
- umbermark/checks/world_writable_suid_audit.py +171 -0
- umbermark/cli/__init__.py +4 -0
- umbermark/cli/app.py +23 -0
- umbermark/cli/auth.py +44 -0
- umbermark/cli/commands/__init__.py +0 -0
- umbermark/cli/commands/doctor.py +16 -0
- umbermark/cli/commands/login.py +80 -0
- umbermark/cli/commands/push.py +54 -0
- umbermark/cli/commands/report.py +26 -0
- umbermark/cli/commands/scan.py +77 -0
- umbermark/cli/commands/scan_local.py +72 -0
- umbermark/cli/commands/scan_wsl.py +35 -0
- umbermark/cli/commands/version.py +11 -0
- umbermark/cli/config.py +13 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/capture.py +228 -0
- umbermark/core/check.py +82 -0
- umbermark/core/checkin.py +89 -0
- umbermark/core/crypto.py +15 -0
- umbermark/core/duration.py +45 -0
- umbermark/core/engine.py +74 -0
- umbermark/core/external/__init__.py +6 -0
- umbermark/core/external/check.py +54 -0
- umbermark/core/external/checks/__init__.py +31 -0
- umbermark/core/external/checks/exposed_datastores.py +55 -0
- umbermark/core/external/checks/http_security_headers.py +249 -0
- umbermark/core/external/checks/legacy_services.py +52 -0
- umbermark/core/external/checks/open_management_ports.py +65 -0
- umbermark/core/external/checks/ssh_alt_ports.py +77 -0
- umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
- umbermark/core/external/checks/tls_hygiene.py +107 -0
- umbermark/core/external/engine.py +89 -0
- umbermark/core/external/ownership.py +607 -0
- umbermark/core/external/probe.py +103 -0
- umbermark/core/external/socket_probe.py +400 -0
- umbermark/core/firewall.py +713 -0
- umbermark/core/fleet.py +92 -0
- umbermark/core/history.py +235 -0
- umbermark/core/html_report.py +306 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +44 -0
- umbermark/core/partial.py +57 -0
- umbermark/core/pdf/__init__.py +0 -0
- umbermark/core/pdf/renderer.py +26 -0
- umbermark/core/pdf/weasyprint_renderer.py +18 -0
- umbermark/core/platform.py +73 -0
- umbermark/core/redact.py +75 -0
- umbermark/core/report.py +25 -0
- umbermark/core/scoring.py +105 -0
- umbermark/core/settings.py +238 -0
- umbermark/core/short_id.py +27 -0
- umbermark/core/signing.py +24 -0
- umbermark/core/ssh.py +156 -0
- umbermark/core/ssh_fingerprint.py +16 -0
- umbermark/core/tiers.py +128 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
- umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Runtime settings loaded from environment.
|
|
2
|
+
|
|
3
|
+
Required env vars (no defaults — caller must set):
|
|
4
|
+
SECRET_KEY — used by itsdangerous for magic links + session signing
|
|
5
|
+
KEY_PEPPER — used by keyenc for saved-server key encryption
|
|
6
|
+
IP_PEPPER — used by crypto.hash_with_pepper for ip_hash
|
|
7
|
+
TARGET_PEPPER — used by crypto.hash_with_pepper for target_host_hash
|
|
8
|
+
DATABASE_PATH — sqlite file path
|
|
9
|
+
REDIS_URL — redis://host:port/db or fakeredis://...
|
|
10
|
+
BASE_URL — canonical https URL for the deployment
|
|
11
|
+
|
|
12
|
+
Optional env vars (with defaults shown):
|
|
13
|
+
SESSION_TTL_SECONDS — default 2_592_000 (30 days)
|
|
14
|
+
ANONYMOUS_REPORT_TTL_SECONDS — default 2_592_000 (30 days)
|
|
15
|
+
POSTMARK_WIRED / TURNSTILE_WIRED — default 0 (wired: false, C3). While 0 the Postmark and Turnstile
|
|
16
|
+
settings below are IGNORED: no email provider, no captcha, no
|
|
17
|
+
Cloudflare origin in the CSP. Polar is the only payment processor.
|
|
18
|
+
POSTMARK_TOKEN — only when POSTMARK_WIRED=1: real-mode email transport
|
|
19
|
+
POLAR_API_TOKEN — if set, real-mode Polar client
|
|
20
|
+
POLAR_WEBHOOK_SECRET — HMAC-SHA256 secret for /billing/webhook
|
|
21
|
+
POLAR_PRODUCT_ONESHOT — Polar product id for $5 one-shot
|
|
22
|
+
POLAR_PRODUCT_SOLO — Polar product id for $9/mo Solo tier
|
|
23
|
+
POLAR_PRODUCT_PRO — Polar product id for $19/mo Pro tier
|
|
24
|
+
POLAR_PRODUCT_AGENCY — Polar product id for $49/mo Agency tier
|
|
25
|
+
POLAR_API_BASE — Polar API base URL; default https://api.polar.sh/v1
|
|
26
|
+
(sandbox: https://sandbox-api.polar.sh/v1)
|
|
27
|
+
UPLOAD_RATE_LIMIT_PER_HOUR — max POST /scan/upload per IP per hour; default 20
|
|
28
|
+
FREE_PUSH_LIMIT_PER_DAY — max POST /api/push per free-tier user per rolling 24h; default 20 (paid tiers uncapped)
|
|
29
|
+
PDF_STORAGE_DIR — on-disk PDF cache directory
|
|
30
|
+
TURNSTILE_SECRET — only when TURNSTILE_WIRED=1: Cloudflare Turnstile siteverify secret
|
|
31
|
+
TURNSTILE_SITE_KEY — only when TURNSTILE_WIRED=1: public site key embedded in widget
|
|
32
|
+
OPERATOR_EMAIL — recipient for operator daily abuse digest
|
|
33
|
+
EMAIL_FROM — outbound from-address; default noreply@<base_url host>
|
|
34
|
+
EMAIL_REPLY_TO — optional single address sent as Postmark ReplyTo on every message
|
|
35
|
+
SIGNIN_ENABLED — default 0. While 0 every sign-in / magic-link / CLI-login /
|
|
36
|
+
claim route answers 403, sessions and API tokens are not
|
|
37
|
+
honoured, and all existing sessions are revoked at boot.
|
|
38
|
+
The quad42 gate flips this once Starhold Account exists.
|
|
39
|
+
STARSHIP_ENABLED — default 0 (STAR-227). While 0 nothing from umbermark.starship is mounted and
|
|
40
|
+
behaviour is unchanged. See umbermark/starship/config.py for the STARSHIP_* settings.
|
|
41
|
+
MONTHLY_PLANS_ENABLED — default 0. While 0 the Solo/Pro/Agency plans are hidden everywhere,
|
|
42
|
+
/billing/upgrade refuses, and subscription webhooks are ignored.
|
|
43
|
+
|
|
44
|
+
External-scan network path (UMBR-65; all unset = old behaviour):
|
|
45
|
+
SCAN_RESOLVERS — comma list of recursive DNS servers (UDP) used to resolve scan targets
|
|
46
|
+
and DNS-TXT proofs, so a name the host maps locally (its own hostname ->
|
|
47
|
+
127.0.1.1) resolves as the internet sees it. Unset = system resolver
|
|
48
|
+
for targets, 1.1.1.1/8.8.8.8 for TXT.
|
|
49
|
+
SCANNER_SELF_IPS — comma list of this server's own public IPs.
|
|
50
|
+
SCANNER_HAIRPIN_ADDR — address that reaches this server from inside the container (rootless
|
|
51
|
+
podman/pasta: 169.254.1.2). With SCANNER_SELF_IPS set, probes of a self IP
|
|
52
|
+
go here, because pasta keeps the host's own IP inside the namespace.
|
|
53
|
+
SCANNER_SELF_PUBLIC_PORTS — default 80,443: the only ports of this server reachable from the
|
|
54
|
+
internet. Other ports on a self IP read as filtered, as from outside.
|
|
55
|
+
"""
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Mapping
|
|
63
|
+
|
|
64
|
+
# Single source of truth for the anonymous report link lifetime (30 days).
|
|
65
|
+
DEFAULT_ANONYMOUS_REPORT_TTL_SECONDS = 2_592_000
|
|
66
|
+
|
|
67
|
+
# Default sign-in session lifetime (30 days); the privacy page quotes it.
|
|
68
|
+
DEFAULT_SESSION_TTL_SECONDS = 2_592_000
|
|
69
|
+
|
|
70
|
+
_REQUIRED = (
|
|
71
|
+
"SECRET_KEY",
|
|
72
|
+
"KEY_PEPPER",
|
|
73
|
+
"IP_PEPPER",
|
|
74
|
+
"TARGET_PEPPER",
|
|
75
|
+
"DATABASE_PATH",
|
|
76
|
+
"REDIS_URL",
|
|
77
|
+
"BASE_URL",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_ADDR_RE = re.compile(r"^[^\s<>,;:\"()@]+@[^\s<>,;:\"()@]+\.[^\s<>,;:\"()@]+$")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _reply_to(value: str | None, name: str = "EMAIL_REPLY_TO") -> str | None:
|
|
85
|
+
"""Optional Reply-To: one plain address (no display name, no list). Unset or blank = None."""
|
|
86
|
+
v = (value or "").strip()
|
|
87
|
+
if not v:
|
|
88
|
+
return None
|
|
89
|
+
if not _ADDR_RE.match(v):
|
|
90
|
+
raise ValueError(f"{name} must be a single plain email address")
|
|
91
|
+
return v
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _flag(raw: str | None) -> bool:
|
|
95
|
+
"""Parse a 0/1 style env switch. Unset or anything unrecognised is OFF."""
|
|
96
|
+
return (raw or "0").strip().lower() in ("1", "true", "yes", "on")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _csv(raw: str | None) -> tuple[str, ...]:
|
|
100
|
+
"""Comma list -> tuple of non-empty, stripped items."""
|
|
101
|
+
return tuple(p.strip() for p in (raw or "").split(",") if p.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Settings:
|
|
106
|
+
secret_key: str
|
|
107
|
+
key_pepper: str
|
|
108
|
+
ip_pepper: str
|
|
109
|
+
target_pepper: str
|
|
110
|
+
database_path: Path
|
|
111
|
+
redis_url: str
|
|
112
|
+
base_url: str
|
|
113
|
+
session_ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS
|
|
114
|
+
anonymous_report_ttl_seconds: int = DEFAULT_ANONYMOUS_REPORT_TTL_SECONDS
|
|
115
|
+
upload_rate_limit_per_hour: int = 20
|
|
116
|
+
free_push_limit_per_day: int = 20
|
|
117
|
+
postmark_token: str | None = None
|
|
118
|
+
polar_api_token: str | None = None
|
|
119
|
+
polar_webhook_secret: str | None = None
|
|
120
|
+
polar_product_oneshot: str | None = None
|
|
121
|
+
polar_product_solo: str | None = None
|
|
122
|
+
polar_product_pro: str | None = None
|
|
123
|
+
polar_product_agency: str | None = None
|
|
124
|
+
polar_api_base: str = "https://api.polar.sh/v1"
|
|
125
|
+
pdf_storage_dir: Path | None = None
|
|
126
|
+
turnstile_secret: str | None = None
|
|
127
|
+
turnstile_site_key: str | None = None
|
|
128
|
+
operator_email: str | None = None
|
|
129
|
+
email_from: str | None = None
|
|
130
|
+
email_reply_to: str | None = None
|
|
131
|
+
# Pause switch: CHECKOUT_PAUSED=1 makes every Polar checkout route 503.
|
|
132
|
+
checkout_paused: bool = False
|
|
133
|
+
# Sign-in switch (STAR-147 / Q43). Closed by default: nobody can sign in
|
|
134
|
+
# until the Starhold Account launches and the gate flips SIGNIN_ENABLED=1.
|
|
135
|
+
signin_enabled: bool = False
|
|
136
|
+
# Monthly plans (Solo/Pro/Agency) are hidden and un-purchasable while 0
|
|
137
|
+
# (Q42/Q44). MONTHLY_PLANS_ENABLED=1 restores the old behaviour for the gate.
|
|
138
|
+
monthly_plans_enabled: bool = False
|
|
139
|
+
# C3: third-party providers that are NOT connected. Their credentials are
|
|
140
|
+
# ignored unless the matching *_WIRED switch is on.
|
|
141
|
+
postmark_wired: bool = False
|
|
142
|
+
turnstile_wired: bool = False
|
|
143
|
+
# STAR-227: Starhold Account integration. Off by default; `starship` is None unless enabled.
|
|
144
|
+
starship_enabled: bool = False
|
|
145
|
+
starship: object | None = None
|
|
146
|
+
# UMBR-65: how external scans reach targets (see module docstring).
|
|
147
|
+
scan_resolvers: tuple[str, ...] = ()
|
|
148
|
+
scanner_self_ips: tuple[str, ...] = ()
|
|
149
|
+
scanner_hairpin_addr: str = ""
|
|
150
|
+
scanner_self_public_ports: tuple[int, ...] = (80, 443)
|
|
151
|
+
|
|
152
|
+
def apply_scan_network(self) -> None:
|
|
153
|
+
"""Install the external-scan network path in this process (web + worker)."""
|
|
154
|
+
from umbermark.core.external import ownership
|
|
155
|
+
|
|
156
|
+
ownership.configure_network(
|
|
157
|
+
resolvers=self.scan_resolvers,
|
|
158
|
+
self_ips=self.scanner_self_ips,
|
|
159
|
+
hairpin_addr=self.scanner_hairpin_addr,
|
|
160
|
+
self_public_ports=self.scanner_self_public_ports,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def billing_enabled(self) -> bool:
|
|
165
|
+
"""True only when a real Polar client can be built (token + one-shot product + webhook secret).
|
|
166
|
+
|
|
167
|
+
When False the app must not offer purchase/upgrade surfaces: main.py
|
|
168
|
+
falls back to FakePolarClient, which hands strangers a fake checkout.
|
|
169
|
+
"""
|
|
170
|
+
# The webhook secret is required too: without it every paid webhook
|
|
171
|
+
# fails signature checks (401) and buyers pay but are never granted.
|
|
172
|
+
return bool(
|
|
173
|
+
self.polar_api_token
|
|
174
|
+
and self.polar_product_oneshot
|
|
175
|
+
and self.polar_webhook_secret
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def email_enabled(self) -> bool:
|
|
180
|
+
"""True only when a real transactional email provider is configured.
|
|
181
|
+
|
|
182
|
+
Without it, magic-link sign-in mail goes to the console and never arrives.
|
|
183
|
+
"""
|
|
184
|
+
return bool(self.postmark_token)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> "Settings":
|
|
188
|
+
e = env if env is not None else os.environ
|
|
189
|
+
missing = [name for name in _REQUIRED if not e.get(name)]
|
|
190
|
+
if missing:
|
|
191
|
+
raise ValueError(f"Missing required env vars: {', '.join(missing)}")
|
|
192
|
+
pdf_dir = e.get("PDF_STORAGE_DIR")
|
|
193
|
+
starship_on = _flag(e.get("STARSHIP_ENABLED"))
|
|
194
|
+
starship_cfg = None
|
|
195
|
+
if starship_on:
|
|
196
|
+
from umbermark.starship.config import StarshipConfig
|
|
197
|
+
|
|
198
|
+
starship_cfg = StarshipConfig.from_env(e, database_path=Path(e["DATABASE_PATH"]))
|
|
199
|
+
return cls(
|
|
200
|
+
secret_key=e["SECRET_KEY"],
|
|
201
|
+
key_pepper=e["KEY_PEPPER"],
|
|
202
|
+
ip_pepper=e["IP_PEPPER"],
|
|
203
|
+
target_pepper=e["TARGET_PEPPER"],
|
|
204
|
+
database_path=Path(e["DATABASE_PATH"]),
|
|
205
|
+
redis_url=e["REDIS_URL"],
|
|
206
|
+
base_url=e["BASE_URL"].rstrip("/"),
|
|
207
|
+
session_ttl_seconds=int(e.get("SESSION_TTL_SECONDS", DEFAULT_SESSION_TTL_SECONDS)),
|
|
208
|
+
anonymous_report_ttl_seconds=int(e.get("ANONYMOUS_REPORT_TTL_SECONDS", DEFAULT_ANONYMOUS_REPORT_TTL_SECONDS)),
|
|
209
|
+
upload_rate_limit_per_hour=int(e.get("UPLOAD_RATE_LIMIT_PER_HOUR") or 20),
|
|
210
|
+
free_push_limit_per_day=int(e.get("FREE_PUSH_LIMIT_PER_DAY") or 20),
|
|
211
|
+
postmark_token=(e.get("POSTMARK_TOKEN") or None) if _flag(e.get("POSTMARK_WIRED")) else None,
|
|
212
|
+
postmark_wired=_flag(e.get("POSTMARK_WIRED")),
|
|
213
|
+
turnstile_wired=_flag(e.get("TURNSTILE_WIRED")),
|
|
214
|
+
polar_api_token=e.get("POLAR_API_TOKEN") or None,
|
|
215
|
+
polar_webhook_secret=e.get("POLAR_WEBHOOK_SECRET") or None,
|
|
216
|
+
polar_product_oneshot=e.get("POLAR_PRODUCT_ONESHOT") or None,
|
|
217
|
+
polar_product_solo=e.get("POLAR_PRODUCT_SOLO") or None,
|
|
218
|
+
polar_product_pro=e.get("POLAR_PRODUCT_PRO") or None,
|
|
219
|
+
polar_product_agency=e.get("POLAR_PRODUCT_AGENCY") or None,
|
|
220
|
+
polar_api_base=e.get("POLAR_API_BASE") or "https://api.polar.sh/v1",
|
|
221
|
+
pdf_storage_dir=Path(pdf_dir) if pdf_dir else None,
|
|
222
|
+
turnstile_secret=(e.get("TURNSTILE_SECRET") or None) if _flag(e.get("TURNSTILE_WIRED")) else None,
|
|
223
|
+
turnstile_site_key=(e.get("TURNSTILE_SITE_KEY") or None) if _flag(e.get("TURNSTILE_WIRED")) else None,
|
|
224
|
+
operator_email=e.get("OPERATOR_EMAIL") or None,
|
|
225
|
+
email_from=e.get("EMAIL_FROM") or None,
|
|
226
|
+
email_reply_to=_reply_to(e.get("EMAIL_REPLY_TO")),
|
|
227
|
+
checkout_paused=_flag(e.get("CHECKOUT_PAUSED")),
|
|
228
|
+
signin_enabled=_flag(e.get("SIGNIN_ENABLED")),
|
|
229
|
+
monthly_plans_enabled=_flag(e.get("MONTHLY_PLANS_ENABLED")),
|
|
230
|
+
starship_enabled=starship_on,
|
|
231
|
+
starship=starship_cfg,
|
|
232
|
+
scan_resolvers=_csv(e.get("SCAN_RESOLVERS")),
|
|
233
|
+
scanner_self_ips=_csv(e.get("SCANNER_SELF_IPS")),
|
|
234
|
+
scanner_hairpin_addr=(e.get("SCANNER_HAIRPIN_ADDR") or "").strip(),
|
|
235
|
+
scanner_self_public_ports=tuple(
|
|
236
|
+
int(p) for p in (_csv(e.get("SCANNER_SELF_PUBLIC_PORTS")) or ("80", "443"))
|
|
237
|
+
),
|
|
238
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Unguessable report identifiers.
|
|
2
|
+
|
|
3
|
+
A report URL is a capability: anyone holding it can read the report (shareable
|
|
4
|
+
by design). So the id must be impossible to guess. 16 random bytes = 128 bits,
|
|
5
|
+
rendered as 26 lowercase base32 characters [a-z2-7]. Ids from before STAR-147
|
|
6
|
+
were 8 characters (40 bits); migration 012 retires them.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
|
|
14
|
+
ID_BYTES = 16 # 128 bits
|
|
15
|
+
ID_LENGTH = 26 # base32 of 16 bytes, unpadded
|
|
16
|
+
|
|
17
|
+
_FORMAT = re.compile(r"^[a-z2-7]{26}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate() -> str:
|
|
21
|
+
"""Return a 26-char [a-z2-7] id carrying 128 random bits."""
|
|
22
|
+
raw = base64.b32encode(secrets.token_bytes(ID_BYTES)).decode("ascii").lower()
|
|
23
|
+
return raw.rstrip("=")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_current_format(short_id: str) -> bool:
|
|
27
|
+
return bool(_FORMAT.match(short_id or ""))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Thin wrapper over `itsdangerous.URLSafeTimedSerializer`.
|
|
2
|
+
|
|
3
|
+
Used for magic-link tokens and any other time-limited signed payload.
|
|
4
|
+
The wrapper takes a `salt` parameter so different token kinds (login link
|
|
5
|
+
vs. report claim vs. CLI device token) can't be cross-redeemed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
12
|
+
|
|
13
|
+
__all__ = ["sign_token", "verify_token", "BadSignature", "SignatureExpired"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sign_token(payload: Any, secret_key: str, salt: str) -> str:
|
|
17
|
+
return URLSafeTimedSerializer(secret_key, salt=salt).dumps(payload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_token(token: str, secret_key: str, salt: str, max_age_seconds: int) -> Any:
|
|
21
|
+
"""Returns the original payload. Raises SignatureExpired or BadSignature."""
|
|
22
|
+
return URLSafeTimedSerializer(secret_key, salt=salt).loads(
|
|
23
|
+
token, max_age=max_age_seconds
|
|
24
|
+
)
|
umbermark/core/ssh.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Command-runner protocol + paramiko (real SSH), WSL (dogfood), fake (tests)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CommandResult:
|
|
11
|
+
stdout: str
|
|
12
|
+
stderr: str
|
|
13
|
+
exit_code: int
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def ok(self) -> bool:
|
|
17
|
+
return self.exit_code == 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandRunner(Protocol):
|
|
21
|
+
def run(
|
|
22
|
+
self,
|
|
23
|
+
command: str,
|
|
24
|
+
sudo: bool = False,
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
) -> CommandResult:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FakeRunner:
|
|
32
|
+
"""Test double — programmable command-string -> CommandResult mapping.
|
|
33
|
+
|
|
34
|
+
A response registered under the bare command matches whether or not
|
|
35
|
+
`sudo=True` was passed, so tests don't have to mirror the runner's
|
|
36
|
+
sudo-prefixing convention.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
responses: dict[str, CommandResult] = field(default_factory=dict)
|
|
40
|
+
calls: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def run(
|
|
43
|
+
self,
|
|
44
|
+
command: str,
|
|
45
|
+
sudo: bool = False,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
) -> CommandResult:
|
|
48
|
+
full_cmd = f"sudo -n {command}" if sudo else command
|
|
49
|
+
self.calls.append(full_cmd)
|
|
50
|
+
if command in self.responses:
|
|
51
|
+
return self.responses[command]
|
|
52
|
+
if full_cmd in self.responses:
|
|
53
|
+
return self.responses[full_cmd]
|
|
54
|
+
return CommandResult(
|
|
55
|
+
stdout="",
|
|
56
|
+
stderr=f"no fake response for: {command!r}",
|
|
57
|
+
exit_code=127,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ParamikoRunner:
|
|
62
|
+
"""Real SSH runner via paramiko."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
host: str,
|
|
67
|
+
user: str,
|
|
68
|
+
key_filename: str | None = None,
|
|
69
|
+
port: int = 22,
|
|
70
|
+
password: str | None = None,
|
|
71
|
+
connect_timeout: float = 10.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
import paramiko
|
|
74
|
+
|
|
75
|
+
self._client = paramiko.SSHClient()
|
|
76
|
+
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
77
|
+
connect_kwargs: dict = {
|
|
78
|
+
"hostname": host,
|
|
79
|
+
"username": user,
|
|
80
|
+
"port": port,
|
|
81
|
+
"timeout": connect_timeout,
|
|
82
|
+
}
|
|
83
|
+
if key_filename:
|
|
84
|
+
connect_kwargs["key_filename"] = key_filename
|
|
85
|
+
elif password:
|
|
86
|
+
connect_kwargs["password"] = password
|
|
87
|
+
self._client.connect(**connect_kwargs)
|
|
88
|
+
|
|
89
|
+
def run(
|
|
90
|
+
self,
|
|
91
|
+
command: str,
|
|
92
|
+
sudo: bool = False,
|
|
93
|
+
timeout: float = 30.0,
|
|
94
|
+
) -> CommandResult:
|
|
95
|
+
full = f"sudo -n {command}" if sudo else command
|
|
96
|
+
_, stdout, stderr = self._client.exec_command(full, timeout=timeout)
|
|
97
|
+
out = stdout.read().decode("utf-8", errors="replace")
|
|
98
|
+
err = stderr.read().decode("utf-8", errors="replace")
|
|
99
|
+
rc = stdout.channel.recv_exit_status()
|
|
100
|
+
return CommandResult(stdout=out, stderr=err, exit_code=rc)
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
try:
|
|
104
|
+
self._client.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WSLRunner:
|
|
110
|
+
"""Run commands inside a WSL distro via `wsl.exe`. Local dogfood only —
|
|
111
|
+
not a substitute for SSH against real targets."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
distro: str | None = None,
|
|
116
|
+
user: str | None = None,
|
|
117
|
+
wsl_exe: str = "wsl.exe",
|
|
118
|
+
) -> None:
|
|
119
|
+
self.distro = distro
|
|
120
|
+
self.user = user
|
|
121
|
+
self.wsl_exe = wsl_exe
|
|
122
|
+
|
|
123
|
+
def _build_cmd(self, command: str, sudo: bool) -> list[str]:
|
|
124
|
+
cmd: list[str] = [self.wsl_exe]
|
|
125
|
+
if self.distro:
|
|
126
|
+
cmd += ["-d", self.distro]
|
|
127
|
+
if sudo:
|
|
128
|
+
cmd += ["--user", "root"]
|
|
129
|
+
elif self.user:
|
|
130
|
+
cmd += ["--user", self.user]
|
|
131
|
+
cmd += ["--", "bash", "-c", command]
|
|
132
|
+
return cmd
|
|
133
|
+
|
|
134
|
+
def run(
|
|
135
|
+
self,
|
|
136
|
+
command: str,
|
|
137
|
+
sudo: bool = False,
|
|
138
|
+
timeout: float = 30.0,
|
|
139
|
+
) -> CommandResult:
|
|
140
|
+
try:
|
|
141
|
+
p = subprocess.run(
|
|
142
|
+
self._build_cmd(command, sudo),
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=timeout,
|
|
146
|
+
encoding="utf-8",
|
|
147
|
+
errors="replace",
|
|
148
|
+
)
|
|
149
|
+
return CommandResult(
|
|
150
|
+
stdout=p.stdout, stderr=p.stderr, exit_code=p.returncode
|
|
151
|
+
)
|
|
152
|
+
except subprocess.TimeoutExpired:
|
|
153
|
+
return CommandResult(stdout="", stderr="timeout", exit_code=124)
|
|
154
|
+
|
|
155
|
+
def close(self) -> None: # parity with ParamikoRunner
|
|
156
|
+
return None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SHA-256 fingerprint of an SSH private key for audit logging.
|
|
2
|
+
|
|
3
|
+
Per spec §17 — the audit log records a fingerprint so we can correlate
|
|
4
|
+
repeat scan attempts by the same key without ever logging the private
|
|
5
|
+
bytes themselves. The fingerprint is SHA-256 of the stripped UTF-8
|
|
6
|
+
bytes of the PEM. Exact derivation does not need to match openssh's
|
|
7
|
+
public-key fingerprint — only same-key→same-fingerprint and no leak.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def derive_key_fingerprint(private_key_pem: str) -> str:
|
|
15
|
+
payload = private_key_pem.strip().encode("utf-8")
|
|
16
|
+
return hashlib.sha256(payload).hexdigest()
|
umbermark/core/tiers.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The one tier table (UMBR-19): what each plan allows. Every limit in Umbermark reads from here.
|
|
2
|
+
|
|
3
|
+
Prices are display-only (what a buyer pays is set at Starship / the payment provider). Starship's catalog.yaml
|
|
4
|
+
carries the same numbers for the three paid plans; tests/unit/test_tiers.py pins both to the operator-approved table.
|
|
5
|
+
|
|
6
|
+
Limits
|
|
7
|
+
max_servers saved servers the plan may hold (0 = none can be saved).
|
|
8
|
+
min_rescan_days shortest allowed gap between scheduled re-scans (None = no scheduled re-scans at all).
|
|
9
|
+
|
|
10
|
+
Enterprise is a contact-only plan sized by hand; it is not a tier here.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Tier:
|
|
19
|
+
name: str
|
|
20
|
+
price_cents: int
|
|
21
|
+
max_servers: int
|
|
22
|
+
min_rescan_days: int | None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def label(self) -> str:
|
|
26
|
+
return self.name.capitalize()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TIERS: dict[str, Tier] = {
|
|
30
|
+
"free": Tier("free", 0, 0, None),
|
|
31
|
+
"solo": Tier("solo", 900, 7, 7),
|
|
32
|
+
"pro": Tier("pro", 1900, 15, 1),
|
|
33
|
+
"agency": Tier("agency", 4900, 50, 1),
|
|
34
|
+
}
|
|
35
|
+
PAID_TIERS: tuple[str, ...] = ("solo", "pro", "agency")
|
|
36
|
+
RANK: dict[str, int] = {"free": 0, "solo": 1, "pro": 2, "agency": 3} # best one wins
|
|
37
|
+
|
|
38
|
+
# Schedule names the forms accept, and the gap each one means. "manual" has no gap.
|
|
39
|
+
SCHEDULE_DAYS: dict[str, int] = {"daily": 1, "weekly": 7, "monthly": 30}
|
|
40
|
+
SCHEDULES: tuple[str, ...] = ("manual", "daily", "weekly", "monthly")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# UMBR-66: features beyond saved servers and re-scans, and the cheapest plan that includes each one.
|
|
44
|
+
# The pricing page prints FEATURE_LABELS for every plan that has the feature, so only list what is built.
|
|
45
|
+
FEATURES: dict[str, str] = {
|
|
46
|
+
"accepted_risk": "solo",
|
|
47
|
+
"checkin": "solo",
|
|
48
|
+
"fleet": "pro",
|
|
49
|
+
"alert_channels": "pro",
|
|
50
|
+
}
|
|
51
|
+
FEATURE_LABELS: dict[str, str] = {
|
|
52
|
+
"accepted_risk": "Accepted risks: mark a finding as accepted with a reason and an expiry; your score shows both",
|
|
53
|
+
"checkin": "Scheduled check-ins without SSH access: a cron job on your server uploads a scan; alert if one is missed",
|
|
54
|
+
"fleet": "Fleet view: every server on one page, worst score first, with trend",
|
|
55
|
+
"alert_channels": "Score-drop alerts to your own webhook, Slack or Discord, as well as email",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_feature(tier: Tier, feature: str) -> bool:
|
|
60
|
+
"""True when the tier includes `feature`. An unknown feature is never included (fail closed)."""
|
|
61
|
+
need = FEATURES.get(feature)
|
|
62
|
+
return need is not None and RANK.get(tier.name, 0) >= RANK[need]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def plan_features(tier: Tier) -> list[str]:
|
|
66
|
+
"""Pricing-card lines for the gated features this tier includes, in FEATURES order."""
|
|
67
|
+
return [FEATURE_LABELS[f] for f in FEATURES if has_feature(tier, f)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def feature_message(tier: Tier, feature: str) -> str:
|
|
71
|
+
"""Why a gated page is not available on this plan, and which plan has it."""
|
|
72
|
+
need = TIERS[FEATURES[feature]].label
|
|
73
|
+
return f"This comes with the {need} plan and up. Your plan is {tier.label}."
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tier_of(name: str | None) -> Tier:
|
|
77
|
+
"""The tier for a stored name; anything unknown is treated as free (fail closed)."""
|
|
78
|
+
return TIERS.get(name or "free", TIERS["free"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def allowed_schedules(tier: Tier) -> list[str]:
|
|
82
|
+
"""Schedule choices the tier may use, fastest first ("manual" is always allowed)."""
|
|
83
|
+
if tier.min_rescan_days is None:
|
|
84
|
+
return ["manual"]
|
|
85
|
+
ok = [s for s, d in SCHEDULE_DAYS.items() if d >= tier.min_rescan_days]
|
|
86
|
+
return ok + ["manual"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def schedule_allowed(tier: Tier, schedule: str | None) -> bool:
|
|
90
|
+
return (schedule or "manual") in allowed_schedules(tier)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fastest_schedule(tier: Tier) -> str | None:
|
|
94
|
+
"""The fastest scheduled re-scan the tier may use (None when it may not schedule)."""
|
|
95
|
+
ok = allowed_schedules(tier)
|
|
96
|
+
return ok[0] if ok[0] != "manual" else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def server_limit_message(tier: Tier, count: int) -> str:
|
|
100
|
+
n = tier.max_servers
|
|
101
|
+
msg = f"Your {tier.label} plan allows {n} saved server{'' if n == 1 else 's'}; you have {count}."
|
|
102
|
+
if n == 0:
|
|
103
|
+
return msg + " Saved servers come with a paid plan; see /pricing."
|
|
104
|
+
return msg + " Delete one, or move to a bigger plan at /pricing."
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def paused_message(tier: Tier) -> str:
|
|
108
|
+
"""Why a saved server over the plan's limit is paused, and the way back (UMBR-66)."""
|
|
109
|
+
if tier.max_servers == 0:
|
|
110
|
+
return (f"Paused: your {tier.label} plan has no saved servers. Nothing is deleted; "
|
|
111
|
+
"a paid plan resumes it.")
|
|
112
|
+
return (f"Paused: your {tier.label} plan allows {tier.max_servers} saved servers and this one is over the limit. "
|
|
113
|
+
"Nothing is deleted; delete another server or move to a bigger plan and it resumes.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start_offer() -> str:
|
|
117
|
+
"""The cheapest plan that can hold saved servers, as one phrase: '7 servers for $9/month'."""
|
|
118
|
+
t = TIERS[PAID_TIERS[0]]
|
|
119
|
+
return f"{t.max_servers} servers for ${t.price_cents // 100}/month"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def schedule_message(tier: Tier, schedule: str) -> str:
|
|
123
|
+
fastest = fastest_schedule(tier)
|
|
124
|
+
if fastest is None:
|
|
125
|
+
return f"Scheduled re-scans come with a paid plan. Your {tier.label} plan is manual only; see /pricing."
|
|
126
|
+
choices = ", ".join(s for s in allowed_schedules(tier) if s != "manual")
|
|
127
|
+
return (f"Your {tier.label} plan can re-scan {fastest} at most, so {schedule} is not available. "
|
|
128
|
+
f"Choose {choices} or manual.")
|