umbermark-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umbermark/__init__.py +18 -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 +14 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/branding.py +71 -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/evidence.py +83 -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 +308 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +55 -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/remediation.py +121 -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 +138 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0.dist-info/RECORD +96 -0
- umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Weighted scoring engine.
|
|
2
|
+
|
|
3
|
+
Score starts at 100. FAIL deducts the check's full weight; WARN deducts
|
|
4
|
+
half; PASS and SKIP deduct nothing. The result is clamped to [0, 100].
|
|
5
|
+
|
|
6
|
+
`coverage` is the fraction of checks that actually produced a verdict
|
|
7
|
+
(PASS / WARN / FAIL) — i.e. not SKIP. When coverage falls below
|
|
8
|
+
COVERAGE_THRESHOLD the band is forced to "incomplete" regardless of
|
|
9
|
+
the numeric score. This is the load-bearing fix for the dogfood-day
|
|
10
|
+
finding that "100/100 gold with 3/5 checks skipped" misled the reader.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from umbermark.core.check import Finding, Verdict
|
|
18
|
+
|
|
19
|
+
COVERAGE_THRESHOLD = 0.6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Deduction:
|
|
24
|
+
check_id: str
|
|
25
|
+
title: str
|
|
26
|
+
severity: str
|
|
27
|
+
weight: int
|
|
28
|
+
verdict: str
|
|
29
|
+
points_lost: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ScoreResult:
|
|
34
|
+
score: int
|
|
35
|
+
band: str
|
|
36
|
+
coverage: float
|
|
37
|
+
deductions: list[Deduction]
|
|
38
|
+
pass_count: int
|
|
39
|
+
warn_count: int
|
|
40
|
+
fail_count: int
|
|
41
|
+
skip_count: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _band(score: int, coverage: float = 1.0) -> str:
|
|
45
|
+
if coverage < COVERAGE_THRESHOLD:
|
|
46
|
+
return "incomplete"
|
|
47
|
+
if score >= 90:
|
|
48
|
+
return "gold"
|
|
49
|
+
if score >= 70:
|
|
50
|
+
return "green"
|
|
51
|
+
if score >= 40:
|
|
52
|
+
return "yellow"
|
|
53
|
+
return "red"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def score(findings: Iterable[Finding]) -> ScoreResult:
|
|
57
|
+
deductions: list[Deduction] = []
|
|
58
|
+
total = 100
|
|
59
|
+
pc = wc = fc = sc = 0
|
|
60
|
+
for f in findings:
|
|
61
|
+
if f.verdict == Verdict.PASS:
|
|
62
|
+
pc += 1
|
|
63
|
+
elif f.verdict == Verdict.WARN:
|
|
64
|
+
wc += 1
|
|
65
|
+
lost = f.weight // 2
|
|
66
|
+
total -= lost
|
|
67
|
+
deductions.append(
|
|
68
|
+
Deduction(
|
|
69
|
+
check_id=f.check_id,
|
|
70
|
+
title=f.title,
|
|
71
|
+
severity=f.severity.value,
|
|
72
|
+
weight=f.weight,
|
|
73
|
+
verdict=f.verdict.value,
|
|
74
|
+
points_lost=lost,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
elif f.verdict == Verdict.FAIL:
|
|
78
|
+
fc += 1
|
|
79
|
+
total -= f.weight
|
|
80
|
+
deductions.append(
|
|
81
|
+
Deduction(
|
|
82
|
+
check_id=f.check_id,
|
|
83
|
+
title=f.title,
|
|
84
|
+
severity=f.severity.value,
|
|
85
|
+
weight=f.weight,
|
|
86
|
+
verdict=f.verdict.value,
|
|
87
|
+
points_lost=f.weight,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
sc += 1
|
|
92
|
+
total = max(0, min(100, total))
|
|
93
|
+
total_checks = pc + wc + fc + sc
|
|
94
|
+
ran_checks = pc + wc + fc
|
|
95
|
+
coverage = (ran_checks / total_checks) if total_checks > 0 else 0.0
|
|
96
|
+
return ScoreResult(
|
|
97
|
+
score=total,
|
|
98
|
+
band=_band(total, coverage),
|
|
99
|
+
coverage=coverage,
|
|
100
|
+
deductions=deductions,
|
|
101
|
+
pass_count=pc,
|
|
102
|
+
warn_count=wc,
|
|
103
|
+
fail_count=fc,
|
|
104
|
+
skip_count=sc,
|
|
105
|
+
)
|
|
@@ -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()
|