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
umbermark/core/tiers.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
"subscriber_pdf": "solo",
|
|
49
|
+
"fleet": "pro",
|
|
50
|
+
"alert_channels": "pro",
|
|
51
|
+
"api": "pro",
|
|
52
|
+
"evidence_pack": "pro",
|
|
53
|
+
"remediation_bundle": "pro",
|
|
54
|
+
"white_label": "agency",
|
|
55
|
+
}
|
|
56
|
+
FEATURE_LABELS: dict[str, str] = {
|
|
57
|
+
"accepted_risk": "Accepted risks: mark a finding as accepted with a reason and an expiry; your score shows both",
|
|
58
|
+
"checkin": "Scheduled check-ins without SSH access: a cron job on your server uploads a scan; alert if one is missed",
|
|
59
|
+
"subscriber_pdf": "PDF download of every report in your account",
|
|
60
|
+
"fleet": "Fleet view: every server on one page, worst score first, with trend",
|
|
61
|
+
"alert_channels": "Score-drop alerts to your own webhook, Slack or Discord, as well as email",
|
|
62
|
+
"api": "Read API with scoped tokens: scores, history and reports as JSON or CSV, and start scans",
|
|
63
|
+
"evidence_pack": "Evidence pack per report: every finding with its CIS Controls v8 safeguards and accepted risks, as CSV and JSON",
|
|
64
|
+
"remediation_bundle": "Remediation bundle per server: one shell file with the fix steps for its open findings; nothing runs until you name a fix",
|
|
65
|
+
"white_label": "White-label: your name, logo and colour on PDFs and on live client links (read-only, revocable, one per client)",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def has_feature(tier: Tier, feature: str) -> bool:
|
|
70
|
+
"""True when the tier includes `feature`. An unknown feature is never included (fail closed)."""
|
|
71
|
+
need = FEATURES.get(feature)
|
|
72
|
+
return need is not None and RANK.get(tier.name, 0) >= RANK[need]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plan_features(tier: Tier) -> list[str]:
|
|
76
|
+
"""Pricing-card lines for the gated features this tier includes, in FEATURES order."""
|
|
77
|
+
return [FEATURE_LABELS[f] for f in FEATURES if has_feature(tier, f)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def feature_message(tier: Tier, feature: str) -> str:
|
|
81
|
+
"""Why a gated page is not available on this plan, and which plan has it."""
|
|
82
|
+
need = TIERS[FEATURES[feature]].label
|
|
83
|
+
return f"This comes with the {need} plan and up. Your plan is {tier.label}."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def tier_of(name: str | None) -> Tier:
|
|
87
|
+
"""The tier for a stored name; anything unknown is treated as free (fail closed)."""
|
|
88
|
+
return TIERS.get(name or "free", TIERS["free"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def allowed_schedules(tier: Tier) -> list[str]:
|
|
92
|
+
"""Schedule choices the tier may use, fastest first ("manual" is always allowed)."""
|
|
93
|
+
if tier.min_rescan_days is None:
|
|
94
|
+
return ["manual"]
|
|
95
|
+
ok = [s for s, d in SCHEDULE_DAYS.items() if d >= tier.min_rescan_days]
|
|
96
|
+
return ok + ["manual"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def schedule_allowed(tier: Tier, schedule: str | None) -> bool:
|
|
100
|
+
return (schedule or "manual") in allowed_schedules(tier)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fastest_schedule(tier: Tier) -> str | None:
|
|
104
|
+
"""The fastest scheduled re-scan the tier may use (None when it may not schedule)."""
|
|
105
|
+
ok = allowed_schedules(tier)
|
|
106
|
+
return ok[0] if ok[0] != "manual" else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def server_limit_message(tier: Tier, count: int) -> str:
|
|
110
|
+
n = tier.max_servers
|
|
111
|
+
msg = f"Your {tier.label} plan allows {n} saved server{'' if n == 1 else 's'}; you have {count}."
|
|
112
|
+
if n == 0:
|
|
113
|
+
return msg + " Saved servers come with a paid plan; see /pricing."
|
|
114
|
+
return msg + " Delete one, or move to a bigger plan at /pricing."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def paused_message(tier: Tier) -> str:
|
|
118
|
+
"""Why a saved server over the plan's limit is paused, and the way back (UMBR-66)."""
|
|
119
|
+
if tier.max_servers == 0:
|
|
120
|
+
return (f"Paused: your {tier.label} plan has no saved servers. Nothing is deleted; "
|
|
121
|
+
"a paid plan resumes it.")
|
|
122
|
+
return (f"Paused: your {tier.label} plan allows {tier.max_servers} saved servers and this one is over the limit. "
|
|
123
|
+
"Nothing is deleted; delete another server or move to a bigger plan and it resumes.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def start_offer() -> str:
|
|
127
|
+
"""The cheapest plan that can hold saved servers, as one phrase: '7 servers for $9/month'."""
|
|
128
|
+
t = TIERS[PAID_TIERS[0]]
|
|
129
|
+
return f"{t.max_servers} servers for ${t.price_cents // 100}/month"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def schedule_message(tier: Tier, schedule: str) -> str:
|
|
133
|
+
fastest = fastest_schedule(tier)
|
|
134
|
+
if fastest is None:
|
|
135
|
+
return f"Scheduled re-scans come with a paid plan. Your {tier.label} plan is manual only; see /pricing."
|
|
136
|
+
choices = ", ".join(s for s in allowed_schedules(tier) if s != "manual")
|
|
137
|
+
return (f"Your {tier.label} plan can re-scan {fastest} at most, so {schedule} is not available. "
|
|
138
|
+
f"Choose {choices} or manual.")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Outbound alert webhooks: URL validation, SSRF-safe delivery, message bodies (UMBR-66 alert channels).
|
|
2
|
+
|
|
3
|
+
Three kinds of destination, all customer-configured:
|
|
4
|
+
webhook any https URL; gets JSON, signed with HMAC-SHA256 (X-Umbermark-Signature: sha256=<hex>)
|
|
5
|
+
slack https://hooks.slack.com/services/... ({"text": ...})
|
|
6
|
+
discord https://discord.com/api/webhooks/... ({"content": ...}; discordapp.com accepted)
|
|
7
|
+
|
|
8
|
+
SSRF rules (every send, not only on save):
|
|
9
|
+
* https only, port 443 only for generic webhooks, no credentials in the URL, no IP-literal hosts;
|
|
10
|
+
* the host must resolve and EVERY address must be public (core.external.ownership.resolve_public_ips refuses
|
|
11
|
+
loopback, RFC1918, CGNAT/tailnet, link-local/metadata, multicast and the rest);
|
|
12
|
+
* the TCP connection goes to the address that was checked (pinned), with SNI and certificate checks against
|
|
13
|
+
the hostname, so a DNS answer cannot change between the check and the connect;
|
|
14
|
+
* redirects are never followed (a 3xx counts as a failed delivery);
|
|
15
|
+
* short timeouts and a capped response read.
|
|
16
|
+
The host's egress firewall (deploy/umk-egress.nft) blocks the same ranges for the app's uid as a second layer.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import http.client
|
|
23
|
+
import json
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from urllib.parse import urlsplit
|
|
28
|
+
|
|
29
|
+
KINDS: tuple[str, ...] = ("webhook", "slack", "discord")
|
|
30
|
+
KIND_LABELS = {"webhook": "Webhook (JSON)", "slack": "Slack", "discord": "Discord"}
|
|
31
|
+
MAX_URL_LEN = 500
|
|
32
|
+
TIMEOUT_SECONDS = 8
|
|
33
|
+
_SLACK_HOSTS = {"hooks.slack.com"}
|
|
34
|
+
_DISCORD_HOSTS = {"discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebhookError(ValueError):
|
|
38
|
+
"""The URL is not acceptable, or delivery was refused before any bytes were sent."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Target:
|
|
43
|
+
host: str
|
|
44
|
+
port: int
|
|
45
|
+
path: str # path + query, as sent
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate(kind: str, url: str) -> Target:
|
|
49
|
+
"""Shape checks only (no DNS). Raises WebhookError with a message fit for the form."""
|
|
50
|
+
if kind not in KINDS:
|
|
51
|
+
raise WebhookError("Choose webhook, Slack or Discord")
|
|
52
|
+
url = (url or "").strip()
|
|
53
|
+
if not url or len(url) > MAX_URL_LEN:
|
|
54
|
+
raise WebhookError(f"Paste the full https URL (at most {MAX_URL_LEN} characters)")
|
|
55
|
+
try:
|
|
56
|
+
u = urlsplit(url)
|
|
57
|
+
port = u.port or 443
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise WebhookError("That is not a valid URL") from exc
|
|
60
|
+
host = (u.hostname or "").lower().rstrip(".")
|
|
61
|
+
if u.scheme != "https" or not host:
|
|
62
|
+
raise WebhookError("The URL must start with https://")
|
|
63
|
+
if u.username or u.password:
|
|
64
|
+
raise WebhookError("The URL must not contain a user name or password")
|
|
65
|
+
if port != 443:
|
|
66
|
+
raise WebhookError("Only the standard https port (443) is allowed")
|
|
67
|
+
if _is_ip_literal(host):
|
|
68
|
+
raise WebhookError("Use a host name, not an IP address")
|
|
69
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
70
|
+
if kind == "slack" and (host not in _SLACK_HOSTS or not u.path.startswith("/services/")):
|
|
71
|
+
raise WebhookError("A Slack incoming-webhook URL starts with https://hooks.slack.com/services/")
|
|
72
|
+
if kind == "discord" and (host not in _DISCORD_HOSTS or not u.path.startswith("/api/webhooks/")):
|
|
73
|
+
raise WebhookError("A Discord webhook URL starts with https://discord.com/api/webhooks/")
|
|
74
|
+
return Target(host, port, path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def masked(url: str) -> str:
|
|
78
|
+
"""What the page shows: scheme, host and the last 4 characters (the rest of a Slack/Discord URL is a secret)."""
|
|
79
|
+
try:
|
|
80
|
+
u = urlsplit(url)
|
|
81
|
+
tail = url[-4:] if len(url) > 12 else ""
|
|
82
|
+
return f"https://{u.hostname}/…{tail}"
|
|
83
|
+
except ValueError:
|
|
84
|
+
return "(invalid URL)"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_ip_literal(host: str) -> bool:
|
|
88
|
+
import ipaddress
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
ipaddress.ip_address(host.strip("[]"))
|
|
92
|
+
return True
|
|
93
|
+
except ValueError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve(host: str) -> str:
|
|
98
|
+
"""The first public address of `host`; WebhookError unless every address is public."""
|
|
99
|
+
from umbermark.core.external.ownership import BlockedTarget, resolve_public_ips
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
return resolve_public_ips(host)[0]
|
|
103
|
+
except BlockedTarget as exc:
|
|
104
|
+
raise WebhookError("That host does not resolve to a public address") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
108
|
+
"""HTTPS to a fixed IP while SNI and the certificate check use the hostname."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, host: str, ip: str, port: int, timeout: float):
|
|
111
|
+
super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
|
|
112
|
+
self._ip = ip
|
|
113
|
+
|
|
114
|
+
def connect(self): # noqa: D401 - http.client hook
|
|
115
|
+
sock = socket.create_connection((self._ip, self.port), self.timeout)
|
|
116
|
+
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def body_for(kind: str, event: dict) -> bytes:
|
|
120
|
+
"""The request body for one alert event (see `drop_event`)."""
|
|
121
|
+
text = event["text"]
|
|
122
|
+
if kind == "slack":
|
|
123
|
+
return json.dumps({"text": text}).encode()
|
|
124
|
+
if kind == "discord":
|
|
125
|
+
return json.dumps({"content": text[:1900], "allowed_mentions": {"parse": []}}).encode()
|
|
126
|
+
return json.dumps({k: v for k, v in event.items() if k != "text"} | {"text": text}, sort_keys=True).encode()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def signature(secret: str, body: bytes) -> str:
|
|
130
|
+
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def send(kind: str, url: str, event: dict, *, secret: str | None = None, resolve=None,
|
|
134
|
+
connection_factory=None) -> int:
|
|
135
|
+
"""Deliver one event. Returns the HTTP status (2xx = delivered). Raises WebhookError before sending when the
|
|
136
|
+
URL or its addresses are not acceptable; OSError/ssl errors propagate for the caller to record."""
|
|
137
|
+
t = validate(kind, url)
|
|
138
|
+
ip = (resolve or _resolve)(t.host)
|
|
139
|
+
connection_factory = connection_factory or _PinnedHTTPS
|
|
140
|
+
body = body_for(kind, event)
|
|
141
|
+
headers = {"Content-Type": "application/json", "User-Agent": "Umbermark-Alerts/1"}
|
|
142
|
+
if kind == "webhook" and secret:
|
|
143
|
+
headers["X-Umbermark-Signature"] = signature(secret, body)
|
|
144
|
+
conn = connection_factory(t.host, ip, t.port, TIMEOUT_SECONDS)
|
|
145
|
+
try:
|
|
146
|
+
conn.request("POST", t.path, body=body, headers=headers)
|
|
147
|
+
resp = conn.getresponse()
|
|
148
|
+
resp.read(4096) # capped; never kept
|
|
149
|
+
return resp.status
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def drop_event(*, label: str, server_id: int, previous: int, current: int, threshold: int, report_url: str) -> dict:
|
|
155
|
+
"""The one alert Umbermark sends today: a score drop at or past the server's threshold."""
|
|
156
|
+
delta = previous - current
|
|
157
|
+
return {
|
|
158
|
+
"event": "score_drop", "server": {"id": server_id, "label": label},
|
|
159
|
+
"score": {"previous": previous, "current": current, "drop": delta, "threshold": threshold},
|
|
160
|
+
"report_url": report_url,
|
|
161
|
+
"text": f"Umbermark: {label} score dropped {delta} points ({previous} -> {current}). {report_url}",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def missed_event(*, label: str, server_id: int, last_checkin_at: str | None, server_url: str) -> dict:
|
|
166
|
+
"""A check-in server did not check in within its interval plus grace (UMBR-66)."""
|
|
167
|
+
last = (last_checkin_at or "never")[:16].replace("T", " ")
|
|
168
|
+
return {
|
|
169
|
+
"event": "missed_checkin", "server": {"id": server_id, "label": label}, "score": None,
|
|
170
|
+
"last_checkin_at": last_checkin_at, "report_url": server_url,
|
|
171
|
+
"text": f"Umbermark: {label} missed its scheduled check-in (last check-in: {last} UTC). {server_url}",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sample_event(label: str = "test") -> dict:
|
|
176
|
+
return {"event": "test", "server": None, "score": None, "report_url": None,
|
|
177
|
+
"text": f"Umbermark: test alert for channel '{label}'. If you can read this, alerts reach you."}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Report wording: how a check's result reads to a human.
|
|
2
|
+
|
|
3
|
+
A check has three strings, kept apart from the check classes so existing
|
|
4
|
+
stored reports (which only carry ``check_id`` + the old ``title``) pick up new
|
|
5
|
+
wording at render time with no migration:
|
|
6
|
+
|
|
7
|
+
* ``control`` neutral name of the thing being checked ("SSH cipher strength").
|
|
8
|
+
Used on SEO pages, the checks index and anywhere no verdict is
|
|
9
|
+
implied.
|
|
10
|
+
* ``pass_text`` what is right on the server (shown on PASS rows).
|
|
11
|
+
* ``fail_text`` what is wrong on the server (shown on FAIL / WARN rows and in
|
|
12
|
+
"What cost you points"). Present tense, no "should".
|
|
13
|
+
|
|
14
|
+
Rule of thumb: ``fail_text`` is a finding, never a control name. A reader who
|
|
15
|
+
only sees the fail text must never think the *good* thing is the problem.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import html
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from markupsafe import Markup
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Wording:
|
|
29
|
+
control: str
|
|
30
|
+
pass_text: str
|
|
31
|
+
fail_text: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
WORDING: dict[str, Wording] = {
|
|
35
|
+
# --- identity / auth ---------------------------------------------------
|
|
36
|
+
"ssh_permit_root_login": Wording(
|
|
37
|
+
"SSH root login",
|
|
38
|
+
"SSH does not allow direct root login",
|
|
39
|
+
"SSH allows direct root login",
|
|
40
|
+
),
|
|
41
|
+
"ssh_password_authentication": Wording(
|
|
42
|
+
"SSH password login",
|
|
43
|
+
"SSH only accepts keys; password login is off",
|
|
44
|
+
"SSH still accepts password logins",
|
|
45
|
+
),
|
|
46
|
+
"ssh_weak_ciphers": Wording(
|
|
47
|
+
"SSH cipher strength",
|
|
48
|
+
"SSH only accepts strong ciphers, MACs and key exchange",
|
|
49
|
+
"SSH still accepts weak ciphers, MACs or key exchange",
|
|
50
|
+
),
|
|
51
|
+
"ssh_non_default_port": Wording(
|
|
52
|
+
"SSH port",
|
|
53
|
+
"SSH listens on a non-default port",
|
|
54
|
+
"SSH listens on the default port 22",
|
|
55
|
+
),
|
|
56
|
+
"sudoers_nopasswd": Wording(
|
|
57
|
+
"Passwordless sudo rules",
|
|
58
|
+
"No risky passwordless sudo rules",
|
|
59
|
+
"Passwordless sudo (NOPASSWD) rules are in place",
|
|
60
|
+
),
|
|
61
|
+
"sudo_requires_authentication": Wording(
|
|
62
|
+
"sudo authentication",
|
|
63
|
+
"sudo asks users to authenticate",
|
|
64
|
+
"sudo lets users in without authenticating",
|
|
65
|
+
),
|
|
66
|
+
"user_password_aging": Wording(
|
|
67
|
+
"Password aging policy",
|
|
68
|
+
"Account passwords have an expiry policy",
|
|
69
|
+
"Some accounts have passwords that never expire",
|
|
70
|
+
),
|
|
71
|
+
"failed_login_anomaly": Wording(
|
|
72
|
+
"Failed SSH logins (7 days)",
|
|
73
|
+
"Few failed SSH logins in the last 7 days",
|
|
74
|
+
"Many failed SSH login attempts in the last 7 days",
|
|
75
|
+
),
|
|
76
|
+
"fail2ban_enabled": Wording(
|
|
77
|
+
"fail2ban brute-force protection",
|
|
78
|
+
"fail2ban is running with active jails",
|
|
79
|
+
"fail2ban is missing, stopped or has no active jails",
|
|
80
|
+
),
|
|
81
|
+
# --- patching ----------------------------------------------------------
|
|
82
|
+
"kernel_age": Wording(
|
|
83
|
+
"Kernel age",
|
|
84
|
+
"The running kernel is recent",
|
|
85
|
+
"The running kernel is more than 6 months old",
|
|
86
|
+
),
|
|
87
|
+
"pending_security_updates": Wording(
|
|
88
|
+
"Security updates",
|
|
89
|
+
"No security updates are waiting",
|
|
90
|
+
"Security updates are waiting to be installed",
|
|
91
|
+
),
|
|
92
|
+
"reboot_pending": Wording(
|
|
93
|
+
"Pending reboot",
|
|
94
|
+
"No reboot is pending",
|
|
95
|
+
"A reboot is pending, so updates are not active yet",
|
|
96
|
+
),
|
|
97
|
+
"auto_updates_configured": Wording(
|
|
98
|
+
"Automatic security updates",
|
|
99
|
+
"Automatic security updates are turned on",
|
|
100
|
+
"Automatic security updates are not set up",
|
|
101
|
+
),
|
|
102
|
+
"repo_gpg_trust": Wording(
|
|
103
|
+
"Package signing keys",
|
|
104
|
+
"Package repositories have signing keys configured",
|
|
105
|
+
"Package repositories have no signing keys configured",
|
|
106
|
+
),
|
|
107
|
+
# --- network -----------------------------------------------------------
|
|
108
|
+
"firewall_protective": Wording(
|
|
109
|
+
"Host firewall",
|
|
110
|
+
"The host firewall blocks unsolicited inbound traffic",
|
|
111
|
+
"The host firewall is off or lets inbound traffic through",
|
|
112
|
+
),
|
|
113
|
+
"time_sync": Wording(
|
|
114
|
+
"Clock synchronisation",
|
|
115
|
+
"The clock is synced and an NTP service is running",
|
|
116
|
+
"The clock is not synced or no NTP service is running",
|
|
117
|
+
),
|
|
118
|
+
"dns_resolver_sanity": Wording(
|
|
119
|
+
"DNS resolver",
|
|
120
|
+
"DNS is configured and answering",
|
|
121
|
+
"DNS is misconfigured or not answering",
|
|
122
|
+
),
|
|
123
|
+
# --- filesystem / services / logging / hardening -----------------------
|
|
124
|
+
"root_disk_free": Wording(
|
|
125
|
+
"Root disk space",
|
|
126
|
+
"The root disk has plenty of free space",
|
|
127
|
+
"The root disk is nearly full",
|
|
128
|
+
),
|
|
129
|
+
"tmp_hardening": Wording(
|
|
130
|
+
"/tmp mount options",
|
|
131
|
+
"/tmp is mounted noexec and nosuid",
|
|
132
|
+
"/tmp allows running programs or setuid files",
|
|
133
|
+
),
|
|
134
|
+
"failed_systemd_units": Wording(
|
|
135
|
+
"Failed systemd units",
|
|
136
|
+
"No systemd units have failed",
|
|
137
|
+
"One or more systemd units have failed",
|
|
138
|
+
),
|
|
139
|
+
"swap_pressure": Wording(
|
|
140
|
+
"Swap pressure",
|
|
141
|
+
"The server is not swapping memory to disk",
|
|
142
|
+
"The server is swapping memory to disk",
|
|
143
|
+
),
|
|
144
|
+
"journald_persistence": Wording(
|
|
145
|
+
"Persistent system logs",
|
|
146
|
+
"System logs survive a reboot",
|
|
147
|
+
"System logs are lost on reboot",
|
|
148
|
+
),
|
|
149
|
+
"mac_framework_enforcing": Wording(
|
|
150
|
+
"AppArmor / SELinux",
|
|
151
|
+
"AppArmor or SELinux is enforcing",
|
|
152
|
+
"AppArmor or SELinux is off or not enforcing",
|
|
153
|
+
),
|
|
154
|
+
"public_listen_anomalies": Wording(
|
|
155
|
+
"Public listening ports",
|
|
156
|
+
"Only expected ports listen on public addresses",
|
|
157
|
+
"Unexpected ports listen on public addresses",
|
|
158
|
+
),
|
|
159
|
+
"listen_vs_firewall_delta": Wording(
|
|
160
|
+
"Firewall cover for listening ports",
|
|
161
|
+
"Public listening ports are covered by a deny-by-default firewall",
|
|
162
|
+
"Public ports are reachable only because the firewall allows everything by default",
|
|
163
|
+
),
|
|
164
|
+
"world_writable_suid_audit": Wording(
|
|
165
|
+
"World-writable files and SUID binaries",
|
|
166
|
+
"No unexpected world-writable files or SUID binaries",
|
|
167
|
+
"Unexpected world-writable files or SUID binaries exist",
|
|
168
|
+
),
|
|
169
|
+
# --- external (outside-in) --------------------------------------------
|
|
170
|
+
"external_exposed_datastores": Wording(
|
|
171
|
+
"Exposed data stores",
|
|
172
|
+
"No database or cache is reachable from the internet",
|
|
173
|
+
"A database or cache is reachable from the internet",
|
|
174
|
+
),
|
|
175
|
+
"external_legacy_plaintext_services": Wording(
|
|
176
|
+
"Legacy plaintext services",
|
|
177
|
+
"No telnet, FTP or r-services are reachable from the internet",
|
|
178
|
+
"A plaintext service (telnet, FTP or r-service) is reachable from the internet",
|
|
179
|
+
),
|
|
180
|
+
"external_open_management_ports": Wording(
|
|
181
|
+
"Remote-management ports",
|
|
182
|
+
"No remote-management ports are reachable from the internet",
|
|
183
|
+
"Remote-management ports (RDP, VNC, Docker, etcd...) are reachable from the internet",
|
|
184
|
+
),
|
|
185
|
+
"external_ssh_weak_algorithms": Wording(
|
|
186
|
+
"SSH algorithms (outside view)",
|
|
187
|
+
"SSH offers only modern algorithms to the internet",
|
|
188
|
+
"SSH offers weak or deprecated algorithms to the internet",
|
|
189
|
+
),
|
|
190
|
+
"external_ssh_alt_ports": Wording(
|
|
191
|
+
"SSH algorithms on alternate ports (outside view)",
|
|
192
|
+
"SSH on alternate ports offers only modern algorithms to the internet",
|
|
193
|
+
"SSH on an alternate port offers weak or deprecated algorithms to the internet",
|
|
194
|
+
),
|
|
195
|
+
"external_tls_hygiene": Wording(
|
|
196
|
+
"TLS and certificate",
|
|
197
|
+
"TLS is modern and the certificate is valid",
|
|
198
|
+
"TLS uses old protocols or the certificate has a problem",
|
|
199
|
+
),
|
|
200
|
+
"external_http_security_headers": Wording(
|
|
201
|
+
"HTTP security headers",
|
|
202
|
+
"HTTPS responses set the core security headers",
|
|
203
|
+
"HTTPS responses are missing or weakly set core security headers",
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def wording_for(check_id: str, fallback_title: str = "") -> Wording:
|
|
209
|
+
"""Wording for a check; unknown ids (old/foreign payloads) fall back to the
|
|
210
|
+
stored title for all three strings."""
|
|
211
|
+
w = WORDING.get(check_id)
|
|
212
|
+
if w is not None:
|
|
213
|
+
return w
|
|
214
|
+
t = fallback_title or check_id
|
|
215
|
+
return Wording(t, t, t)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def control_name(check_id: str, fallback_title: str = "") -> str:
|
|
219
|
+
return wording_for(check_id, fallback_title).control
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- skip reasons -----------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def skip_note(reason: str) -> str:
|
|
225
|
+
"""Public-safe, human reason a check was skipped (no raw command output)."""
|
|
226
|
+
r = (reason or "").lower()
|
|
227
|
+
if not r:
|
|
228
|
+
return "Skipped: no data was available for this check."
|
|
229
|
+
if "ownership" in r or "re-verify" in r or "not probed (unverified)" in r:
|
|
230
|
+
return "Skipped: this check only runs after you verify you own the server."
|
|
231
|
+
if "not applicable" in r or "no protective firewall" in r or "non-systemd" in r:
|
|
232
|
+
return "Skipped: not applicable to this server."
|
|
233
|
+
if "check raised" in r:
|
|
234
|
+
return "Skipped: the check hit an internal error."
|
|
235
|
+
if "requires root" in r or "needs root" in r:
|
|
236
|
+
return "Skipped: needs root access, which this scan did not have."
|
|
237
|
+
if "no tls service" in r or "no https service" in r or "kexinit" in r:
|
|
238
|
+
return "Skipped: the service did not answer on the expected port."
|
|
239
|
+
return "Skipped: the data was not available on this server."
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- presentation -------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def points_lost(verdict: str, weight: int) -> int:
|
|
245
|
+
if verdict == "fail":
|
|
246
|
+
return int(weight)
|
|
247
|
+
if verdict == "warn":
|
|
248
|
+
return int(weight) // 2
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _val(x):
|
|
253
|
+
return x.value if hasattr(x, "value") else x
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def annotate_finding(f: dict) -> dict:
|
|
257
|
+
"""Add display fields to a finding dict (in place) and return it."""
|
|
258
|
+
verdict = str(_val(f.get("verdict", "")))
|
|
259
|
+
w = wording_for(f.get("check_id", ""), f.get("title", ""))
|
|
260
|
+
f["control"] = w.control
|
|
261
|
+
if verdict == "pass":
|
|
262
|
+
f["text"] = w.pass_text
|
|
263
|
+
elif verdict in ("fail", "warn"):
|
|
264
|
+
f["text"] = w.fail_text
|
|
265
|
+
else:
|
|
266
|
+
f["text"] = w.control
|
|
267
|
+
lost = points_lost(verdict, f.get("weight", 0))
|
|
268
|
+
f["points_lost"] = lost
|
|
269
|
+
f["informational"] = verdict == "warn" and lost == 0
|
|
270
|
+
if verdict == "skip":
|
|
271
|
+
f["skip_note"] = f.get("skip_note") or skip_note(f.get("skip_reason", ""))
|
|
272
|
+
f["remediation_html"] = remediation_html(f.get("remediation", ""))
|
|
273
|
+
return f
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def present_payload(payload: dict) -> dict:
|
|
277
|
+
"""Return the payload with display fields added to findings and the
|
|
278
|
+
deduction list split into points-costing vs informational (0-point) rows."""
|
|
279
|
+
findings = [annotate_finding(dict(f)) for f in payload.get("findings", [])]
|
|
280
|
+
out = dict(payload)
|
|
281
|
+
out["findings"] = findings
|
|
282
|
+
by_id = {f.get("check_id"): f for f in findings}
|
|
283
|
+
cost, info = [], []
|
|
284
|
+
for d in payload.get("result", {}).get("deductions", []):
|
|
285
|
+
d = dict(d)
|
|
286
|
+
f = by_id.get(d.get("check_id"))
|
|
287
|
+
d["text"] = f["text"] if f else wording_for(d.get("check_id", ""), d.get("title", "")).fail_text
|
|
288
|
+
(cost if d.get("points_lost", 0) > 0 else info).append(d)
|
|
289
|
+
# Zero-point findings that never produced a Deduction row (e.g. weight 0).
|
|
290
|
+
seen = {d.get("check_id") for d in cost + info}
|
|
291
|
+
for f in findings:
|
|
292
|
+
if f["informational"] and f.get("check_id") not in seen:
|
|
293
|
+
info.append({"check_id": f.get("check_id"), "severity": _val(f.get("severity", "")),
|
|
294
|
+
"verdict": "warn", "points_lost": 0, "text": f["text"]})
|
|
295
|
+
out["cost_deductions"] = cost
|
|
296
|
+
out["info_deductions"] = info
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- remediation text -> safe HTML -------------------------------------------
|
|
301
|
+
|
|
302
|
+
_BULLET = re.compile(r"^\s{1,3}(?:-|\d+\.)\s+(.*)$")
|
|
303
|
+
_BACKTICK = re.compile(r"`([^`]+)`")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _inline(s: str) -> str:
|
|
307
|
+
return _BACKTICK.sub(lambda m: "<code>" + m.group(1) + "</code>", html.escape(s, quote=False))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def remediation_html(text: str) -> Markup:
|
|
311
|
+
"""Render a remediation string: indented (4+ spaces) lines become <pre><code>
|
|
312
|
+
blocks, " - " / " 1. " lines become list items, hard-wrapped prose lines
|
|
313
|
+
are joined into paragraphs, `x` becomes <code>x</code>. Everything is
|
|
314
|
+
HTML-escaped first."""
|
|
315
|
+
if not text:
|
|
316
|
+
return Markup("")
|
|
317
|
+
out: list[str] = []
|
|
318
|
+
para: list[str] = []
|
|
319
|
+
code: list[str] = []
|
|
320
|
+
items: list[str] = []
|
|
321
|
+
|
|
322
|
+
def flush() -> None:
|
|
323
|
+
if para:
|
|
324
|
+
out.append("<p>" + _inline(" ".join(para)) + "</p>")
|
|
325
|
+
para.clear()
|
|
326
|
+
if code:
|
|
327
|
+
out.append("<pre><code>" + html.escape("\n".join(code), quote=False) + "</code></pre>")
|
|
328
|
+
code.clear()
|
|
329
|
+
if items:
|
|
330
|
+
out.append("<ul>" + "".join("<li>" + _inline(i) + "</li>" for i in items) + "</ul>")
|
|
331
|
+
items.clear()
|
|
332
|
+
|
|
333
|
+
for raw in text.split("\n"):
|
|
334
|
+
line = raw.rstrip()
|
|
335
|
+
if not line.strip():
|
|
336
|
+
flush()
|
|
337
|
+
continue
|
|
338
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
339
|
+
b = _BULLET.match(line) if indent < 4 else None
|
|
340
|
+
if b:
|
|
341
|
+
if para or code:
|
|
342
|
+
flush()
|
|
343
|
+
items.append(b.group(1))
|
|
344
|
+
continue
|
|
345
|
+
if items and indent >= 2: # wrapped continuation of a bullet
|
|
346
|
+
items[-1] += " " + line.strip()
|
|
347
|
+
continue
|
|
348
|
+
if indent >= 4:
|
|
349
|
+
if para or items:
|
|
350
|
+
flush()
|
|
351
|
+
code.append(line[4:])
|
|
352
|
+
continue
|
|
353
|
+
if code or items:
|
|
354
|
+
flush()
|
|
355
|
+
para.append(line.strip())
|
|
356
|
+
flush()
|
|
357
|
+
return Markup("".join(out))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- dates ----------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def human_date(iso: str | None) -> str:
|
|
363
|
+
"""'2026-11-21T10:00:00.123456+00:00' -> '21 Nov 2026'. Unparseable input is
|
|
364
|
+
returned unchanged so we never hide a value."""
|
|
365
|
+
if not iso:
|
|
366
|
+
return ""
|
|
367
|
+
try:
|
|
368
|
+
d = dt.datetime.fromisoformat(iso)
|
|
369
|
+
except ValueError:
|
|
370
|
+
return iso
|
|
371
|
+
return f"{d.day} {d.strftime('%b %Y')}"
|