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