umbermark-cli 1.0.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umbermark/__init__.py +3 -0
- umbermark/checks/__init__.py +88 -0
- umbermark/checks/auto_updates_configured.py +149 -0
- umbermark/checks/dns_resolver_sanity.py +47 -0
- umbermark/checks/fail2ban_enabled.py +67 -0
- umbermark/checks/failed_login_anomaly.py +48 -0
- umbermark/checks/failed_systemd_units.py +41 -0
- umbermark/checks/firewall_protective.py +247 -0
- umbermark/checks/journald_persistence.py +55 -0
- umbermark/checks/kernel_age.py +74 -0
- umbermark/checks/listen_vs_firewall_delta.py +136 -0
- umbermark/checks/mac_framework_enforcing.py +97 -0
- umbermark/checks/pending_security_updates.py +188 -0
- umbermark/checks/public_listen_anomalies.py +200 -0
- umbermark/checks/reboot_pending.py +71 -0
- umbermark/checks/repo_gpg_trust.py +97 -0
- umbermark/checks/root_disk_free.py +46 -0
- umbermark/checks/ssh_non_default_port.py +62 -0
- umbermark/checks/ssh_password_auth.py +71 -0
- umbermark/checks/ssh_permit_root.py +59 -0
- umbermark/checks/ssh_weak_ciphers.py +85 -0
- umbermark/checks/sudo_requires_authentication.py +41 -0
- umbermark/checks/sudoers_nopasswd.py +111 -0
- umbermark/checks/swap_pressure.py +51 -0
- umbermark/checks/time_sync.py +55 -0
- umbermark/checks/tmp_hardening.py +45 -0
- umbermark/checks/user_password_aging.py +95 -0
- umbermark/checks/world_writable_suid_audit.py +171 -0
- umbermark/cli/__init__.py +4 -0
- umbermark/cli/app.py +23 -0
- umbermark/cli/auth.py +44 -0
- umbermark/cli/commands/__init__.py +0 -0
- umbermark/cli/commands/doctor.py +16 -0
- umbermark/cli/commands/login.py +80 -0
- umbermark/cli/commands/push.py +54 -0
- umbermark/cli/commands/report.py +26 -0
- umbermark/cli/commands/scan.py +77 -0
- umbermark/cli/commands/scan_local.py +72 -0
- umbermark/cli/commands/scan_wsl.py +35 -0
- umbermark/cli/commands/version.py +11 -0
- umbermark/cli/config.py +13 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/capture.py +228 -0
- umbermark/core/check.py +82 -0
- umbermark/core/checkin.py +89 -0
- umbermark/core/crypto.py +15 -0
- umbermark/core/duration.py +45 -0
- umbermark/core/engine.py +74 -0
- umbermark/core/external/__init__.py +6 -0
- umbermark/core/external/check.py +54 -0
- umbermark/core/external/checks/__init__.py +31 -0
- umbermark/core/external/checks/exposed_datastores.py +55 -0
- umbermark/core/external/checks/http_security_headers.py +249 -0
- umbermark/core/external/checks/legacy_services.py +52 -0
- umbermark/core/external/checks/open_management_ports.py +65 -0
- umbermark/core/external/checks/ssh_alt_ports.py +77 -0
- umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
- umbermark/core/external/checks/tls_hygiene.py +107 -0
- umbermark/core/external/engine.py +89 -0
- umbermark/core/external/ownership.py +607 -0
- umbermark/core/external/probe.py +103 -0
- umbermark/core/external/socket_probe.py +400 -0
- umbermark/core/firewall.py +713 -0
- umbermark/core/fleet.py +92 -0
- umbermark/core/history.py +235 -0
- umbermark/core/html_report.py +306 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +44 -0
- umbermark/core/partial.py +57 -0
- umbermark/core/pdf/__init__.py +0 -0
- umbermark/core/pdf/renderer.py +26 -0
- umbermark/core/pdf/weasyprint_renderer.py +18 -0
- umbermark/core/platform.py +73 -0
- umbermark/core/redact.py +75 -0
- umbermark/core/report.py +25 -0
- umbermark/core/scoring.py +105 -0
- umbermark/core/settings.py +238 -0
- umbermark/core/short_id.py +27 -0
- umbermark/core/signing.py +24 -0
- umbermark/core/ssh.py +156 -0
- umbermark/core/ssh_fingerprint.py +16 -0
- umbermark/core/tiers.py +128 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
- umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
umbermark/core/fleet.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Fleet roll-up for one account: every saved server, worst score first, with its trend (UMBR-66).
|
|
2
|
+
|
|
3
|
+
Pure functions over rows the caller already loaded (the owner's servers and their recent scans), so the route
|
|
4
|
+
stays a thin shell and the ordering rules are unit-testable.
|
|
5
|
+
|
|
6
|
+
Ordering ("worst first"):
|
|
7
|
+
1. servers whose latest scan has a full, comparable score, lowest score first; on a tie the bigger drop first;
|
|
8
|
+
2. servers whose latest scan is not a full score (incomplete coverage or an outside-in partial);
|
|
9
|
+
3. servers never scanned.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from umbermark.core.history import Scan, delta, signed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class FleetRow:
|
|
20
|
+
server: object # SavedServer; typed loosely so this module needs no DB import
|
|
21
|
+
latest: Scan | None
|
|
22
|
+
change: int | None # latest minus the scan before it, when both are full scores
|
|
23
|
+
adjusted: object | None = None # core.accepted.Adjusted for the latest scan, when accepted risks apply
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def effective_score(self) -> int | None:
|
|
27
|
+
"""The score the fleet ranks by: with accepted risks when there are any, else the measured one."""
|
|
28
|
+
if self.latest is None or not self.latest.comparable:
|
|
29
|
+
return None
|
|
30
|
+
return self.adjusted.score if self.adjusted is not None else self.latest.score
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def trend(self) -> str:
|
|
34
|
+
"""One word a screen reader can say: rising, falling, steady, or '' when there is nothing to compare."""
|
|
35
|
+
if self.change is None:
|
|
36
|
+
return ""
|
|
37
|
+
return "rising" if self.change > 0 else "falling" if self.change < 0 else "steady"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def arrow(self) -> str:
|
|
41
|
+
return {"rising": "▲", "falling": "▼", "steady": "="}.get(self.trend, "")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def change_text(self) -> str:
|
|
45
|
+
return signed(self.change)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def rank_key(self) -> tuple:
|
|
49
|
+
if self.latest is None:
|
|
50
|
+
return (2, 0, 0)
|
|
51
|
+
if not self.latest.comparable:
|
|
52
|
+
return (1, 0, 0)
|
|
53
|
+
return (0, self.effective_score, self.change if self.change is not None else 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class FleetSummary:
|
|
58
|
+
rows: list[FleetRow]
|
|
59
|
+
total: int = 0
|
|
60
|
+
scored: int = 0
|
|
61
|
+
average: int | None = None
|
|
62
|
+
dropped: int = 0
|
|
63
|
+
rising: int = 0
|
|
64
|
+
bands: dict[str, int] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build(servers: list, scans_by_server: dict[int, list[Scan]], adjusted_by_server: dict | None = None) -> FleetSummary:
|
|
68
|
+
"""`scans_by_server` maps server id to that server's recent scans, oldest first (the dashboard query).
|
|
69
|
+
`adjusted_by_server` maps server id to the latest scan's score with accepted risks (UMBR-66), when any apply.
|
|
70
|
+
The trend stays measured-vs-measured so an acceptance never shows up as an improvement."""
|
|
71
|
+
adjusted_by_server = adjusted_by_server or {}
|
|
72
|
+
rows = []
|
|
73
|
+
for s in servers:
|
|
74
|
+
scans = scans_by_server.get(s.id, [])
|
|
75
|
+
latest = scans[-1] if scans else None
|
|
76
|
+
prev = scans[-2] if len(scans) > 1 else None
|
|
77
|
+
rows.append(FleetRow(s, latest, delta(latest, prev), adjusted_by_server.get(s.id)))
|
|
78
|
+
rows.sort(key=lambda r: r.rank_key)
|
|
79
|
+
full = [r for r in rows if r.effective_score is not None]
|
|
80
|
+
bands: dict[str, int] = {}
|
|
81
|
+
for r in full:
|
|
82
|
+
b = r.adjusted.band if r.adjusted is not None else r.latest.band
|
|
83
|
+
bands[b] = bands.get(b, 0) + 1
|
|
84
|
+
return FleetSummary(
|
|
85
|
+
rows=rows,
|
|
86
|
+
total=len(rows),
|
|
87
|
+
scored=len(full),
|
|
88
|
+
average=round(sum(r.effective_score for r in full) / len(full)) if full else None,
|
|
89
|
+
dropped=sum(1 for r in rows if r.trend == "falling"),
|
|
90
|
+
rising=sum(1 for r in rows if r.trend == "rising"),
|
|
91
|
+
bands=bands,
|
|
92
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Score history and before/after comparison for a saved server (UMBR-66).
|
|
2
|
+
|
|
3
|
+
Pure functions over stored scan rows: no I/O beyond what the caller hands in.
|
|
4
|
+
Nothing here is public. The routes that call it only serve the server's owner.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from html import escape
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from markupsafe import Markup
|
|
14
|
+
|
|
15
|
+
BAD = ("fail", "warn") # a finding that costs (or flags) something
|
|
16
|
+
INCOMPLETE = "incomplete" # band whose score is not a trustworthy number
|
|
17
|
+
CHART_MAX_POINTS = 60 # the chart shows the latest scans; the table lists them all
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Scan:
|
|
22
|
+
"""One stored scan of a saved server, metadata only."""
|
|
23
|
+
|
|
24
|
+
id: int
|
|
25
|
+
short_id: str
|
|
26
|
+
created_at: str
|
|
27
|
+
score: int
|
|
28
|
+
band: str
|
|
29
|
+
coverage: float
|
|
30
|
+
pass_count: int
|
|
31
|
+
warn_count: int
|
|
32
|
+
fail_count: int
|
|
33
|
+
skip_count: int
|
|
34
|
+
partial: bool = False # UMBR-67: outside-in score of an unverified external scan, not a full score
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def comparable(self) -> bool:
|
|
38
|
+
"""A full, trustworthy score: the only kind that is plotted or used for a delta."""
|
|
39
|
+
return self.band != INCOMPLETE and not self.partial
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def score_text(self) -> str:
|
|
43
|
+
"""What a table cell shows: the score, '?' when there is none, or a marked outside-in partial."""
|
|
44
|
+
if self.partial and self.pass_count + self.warn_count + self.fail_count > 0:
|
|
45
|
+
return f"{self.score} (outside-in, partial)"
|
|
46
|
+
return str(self.score) if self.band != INCOMPLETE else "?"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_from_row(row: Any) -> Scan:
|
|
50
|
+
"""Build a Scan from a row that carries the json_extract count columns."""
|
|
51
|
+
keys = row.keys() if hasattr(row, "keys") else ()
|
|
52
|
+
return Scan(
|
|
53
|
+
id=row["id"], short_id=row["short_id"], created_at=row["created_at"], score=row["score"], band=row["band"],
|
|
54
|
+
coverage=row["coverage"], pass_count=row["pass_count"] or 0, warn_count=row["warn_count"] or 0,
|
|
55
|
+
fail_count=row["fail_count"] or 0, skip_count=row["skip_count"] or 0,
|
|
56
|
+
partial=bool(row["partial"]) if "partial" in keys else False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def delta(newer: Scan | None, older: Scan | None) -> int | None:
|
|
61
|
+
"""Score change newer - older; None when either scan is missing or has no trustworthy score."""
|
|
62
|
+
if newer is None or older is None or not (newer.comparable and older.comparable):
|
|
63
|
+
return None
|
|
64
|
+
return newer.score - older.score
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def signed(n: int | None) -> str:
|
|
68
|
+
"""'+12', '-3', '0' or an en-dash when there is no number."""
|
|
69
|
+
if n is None:
|
|
70
|
+
return "–"
|
|
71
|
+
return f"+{n}" if n > 0 else str(n)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def when(iso: str) -> str:
|
|
75
|
+
"""'2026-11-13 09:30 UTC' from a stored ISO timestamp (falls back to the raw text)."""
|
|
76
|
+
return (iso[:16].replace("T", " ") + " UTC") if len(iso) >= 16 else iso
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Summary:
|
|
81
|
+
"""Headline numbers for one server's history (scans ordered oldest first)."""
|
|
82
|
+
|
|
83
|
+
scans: list[Scan]
|
|
84
|
+
latest: Scan | None
|
|
85
|
+
previous: Scan | None
|
|
86
|
+
first: Scan | None
|
|
87
|
+
since_previous: int | None
|
|
88
|
+
since_first: int | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def summarise(scans: list[Scan]) -> Summary:
|
|
92
|
+
latest = scans[-1] if scans else None
|
|
93
|
+
previous = scans[-2] if len(scans) > 1 else None
|
|
94
|
+
first = scans[0] if len(scans) > 1 else None # "since first scan" needs a different first scan
|
|
95
|
+
return Summary(scans, latest, previous, first, delta(latest, previous), delta(latest, first))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- diff -----------------------------------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class Change:
|
|
102
|
+
check_id: str
|
|
103
|
+
title: str
|
|
104
|
+
before: str # verdict in the earlier scan ("" when the check was not in it)
|
|
105
|
+
after: str # verdict in the later scan ("" when the check was not in it)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class Diff:
|
|
110
|
+
fixed: list[Change] = field(default_factory=list)
|
|
111
|
+
regressed: list[Change] = field(default_factory=list)
|
|
112
|
+
new: list[Change] = field(default_factory=list)
|
|
113
|
+
other: list[Change] = field(default_factory=list) # verdict changed, but neither fixed nor regressed
|
|
114
|
+
unchanged: int = 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _verdicts(payload: dict) -> dict[str, tuple[str, str]]:
|
|
118
|
+
out: dict[str, tuple[str, str]] = {}
|
|
119
|
+
for f in payload.get("findings", []):
|
|
120
|
+
v = f.get("verdict", "")
|
|
121
|
+
out[f.get("check_id", "")] = (str(getattr(v, "value", v)), f.get("title", ""))
|
|
122
|
+
out.pop("", None)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def diff_payloads(before: dict, after: dict) -> Diff:
|
|
127
|
+
"""Classify every check by what happened between two scans of the same server.
|
|
128
|
+
|
|
129
|
+
fixed: fail/warn -> pass regressed: pass -> fail/warn
|
|
130
|
+
new: a check that only the later scan ran
|
|
131
|
+
other: any other verdict change (fail <-> warn, to or from skip)
|
|
132
|
+
unchanged: same verdict in both. Checks only the earlier scan ran are not counted.
|
|
133
|
+
"""
|
|
134
|
+
b, a = _verdicts(before), _verdicts(after)
|
|
135
|
+
d = Diff()
|
|
136
|
+
for cid, (verdict, title) in a.items():
|
|
137
|
+
if cid not in b:
|
|
138
|
+
d.new.append(Change(cid, title, "", verdict))
|
|
139
|
+
continue
|
|
140
|
+
old = b[cid][0]
|
|
141
|
+
change = Change(cid, title or b[cid][1], old, verdict)
|
|
142
|
+
if old == verdict:
|
|
143
|
+
d.unchanged += 1
|
|
144
|
+
elif old in BAD and verdict == "pass":
|
|
145
|
+
d.fixed.append(change)
|
|
146
|
+
elif old == "pass" and verdict in BAD:
|
|
147
|
+
d.regressed.append(change)
|
|
148
|
+
else:
|
|
149
|
+
d.other.append(change)
|
|
150
|
+
return d
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def diff_payload_json(before_json: str, after_json: str) -> Diff:
|
|
154
|
+
def load(s: str) -> dict:
|
|
155
|
+
try:
|
|
156
|
+
v = json.loads(s)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return {}
|
|
159
|
+
return v if isinstance(v, dict) else {}
|
|
160
|
+
|
|
161
|
+
return diff_payloads(load(before_json), load(after_json))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def summary_line(d: Diff, before: Scan, after: Scan) -> str:
|
|
165
|
+
"""'3 fixed, 1 regressed, score 71 -> 78' (the score part is dropped when either score is not comparable)."""
|
|
166
|
+
parts = [f"{len(d.fixed)} fixed", f"{len(d.regressed)} regressed"]
|
|
167
|
+
if d.new:
|
|
168
|
+
parts.append(f"{len(d.new)} new")
|
|
169
|
+
if before.comparable and after.comparable:
|
|
170
|
+
parts.append(f"score {before.score} → {after.score}")
|
|
171
|
+
return ", ".join(parts)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- SVG ------------------------------------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
_W, _H = 640, 200
|
|
177
|
+
_PAD_L, _PAD_R, _PAD_T, _PAD_B = 34, 12, 12, 24
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _points(scans: list[Scan], width: int, height: int, pl: int, pr: int, pt: int, pb: int) -> list[tuple[float, float, Scan]]:
|
|
181
|
+
shown = [s for s in scans if s.comparable][-CHART_MAX_POINTS:]
|
|
182
|
+
n = len(shown)
|
|
183
|
+
inner_w, inner_h = width - pl - pr, height - pt - pb
|
|
184
|
+
pts = []
|
|
185
|
+
for i, s in enumerate(shown):
|
|
186
|
+
x = pl + (inner_w / 2 if n == 1 else inner_w * i / (n - 1))
|
|
187
|
+
y = pt + inner_h * (1 - max(0, min(100, s.score)) / 100)
|
|
188
|
+
pts.append((round(x, 1), round(y, 1), s))
|
|
189
|
+
return pts
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def chart_svg(scans: list[Scan], uid: str = "hist") -> Markup:
|
|
193
|
+
"""Score-over-time line chart as inline SVG: no script, no external reference, labelled for screen readers.
|
|
194
|
+
|
|
195
|
+
The scans table on the page is the text equivalent. Scans with an incomplete band are left off the line.
|
|
196
|
+
"""
|
|
197
|
+
pts = _points(scans, _W, _H, _PAD_L, _PAD_R, _PAD_T, _PAD_B)
|
|
198
|
+
uid = "".join(c for c in uid if c.isalnum() or c in "-_") or "hist"
|
|
199
|
+
if pts:
|
|
200
|
+
desc = (f"{len(pts)} scan{'s' if len(pts) != 1 else ''}, from {pts[0][2].score} on {when(pts[0][2].created_at)} "
|
|
201
|
+
f"to {pts[-1][2].score} on {when(pts[-1][2].created_at)}. The scans table below lists every value.")
|
|
202
|
+
else:
|
|
203
|
+
desc = "No scans with a complete score yet."
|
|
204
|
+
out = [
|
|
205
|
+
f'<svg class="hist-chart" viewBox="0 0 {_W} {_H}" role="img" aria-labelledby="{uid}-t {uid}-d" '
|
|
206
|
+
f'xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">',
|
|
207
|
+
f'<title id="{uid}-t">Hardening score over time</title><desc id="{uid}-d">{escape(desc)}</desc>',
|
|
208
|
+
]
|
|
209
|
+
for g in (0, 50, 100):
|
|
210
|
+
y = round(_PAD_T + (_H - _PAD_T - _PAD_B) * (1 - g / 100), 1)
|
|
211
|
+
out.append(f'<line class="hist-grid" x1="{_PAD_L}" y1="{y}" x2="{_W - _PAD_R}" y2="{y}"/>')
|
|
212
|
+
out.append(f'<text class="hist-axis" x="{_PAD_L - 6}" y="{y + 4}" text-anchor="end">{g}</text>')
|
|
213
|
+
if len(pts) > 1:
|
|
214
|
+
line = " ".join(f"{x},{y}" for x, y, _ in pts)
|
|
215
|
+
out.append(f'<polyline class="hist-line" fill="none" points="{line}"/>')
|
|
216
|
+
for x, y, s in pts:
|
|
217
|
+
out.append(f'<circle class="hist-dot" cx="{x}" cy="{y}" r="4"><title>{s.score} ({escape(s.band)}) on '
|
|
218
|
+
f'{escape(when(s.created_at))}</title></circle>')
|
|
219
|
+
out.append("</svg>")
|
|
220
|
+
return Markup("".join(out))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def sparkline_svg(scans: list[Scan], uid: str = "spark") -> Markup:
|
|
224
|
+
"""A small trend line (latest 20 scans) for the dashboard. Decorative: the delta text beside it carries the meaning."""
|
|
225
|
+
w, h = 96, 24
|
|
226
|
+
pts = _points(scans[-20:], w, h, 3, 3, 3, 3)
|
|
227
|
+
if not pts:
|
|
228
|
+
return Markup("")
|
|
229
|
+
out = [f'<svg class="hist-spark" viewBox="0 0 {w} {h}" width="{w}" height="{h}" aria-hidden="true" focusable="false" '
|
|
230
|
+
f'xmlns="http://www.w3.org/2000/svg">']
|
|
231
|
+
if len(pts) > 1:
|
|
232
|
+
out.append('<polyline class="hist-line" fill="none" points="' + " ".join(f"{x},{y}" for x, y, _ in pts) + '"/>')
|
|
233
|
+
x, y, _ = pts[-1]
|
|
234
|
+
out.append(f'<circle class="hist-dot" cx="{x}" cy="{y}" r="2.5"/></svg>')
|
|
235
|
+
return Markup("".join(out))
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""HTML report rendering — owner view (full) and public view (scrubbed).
|
|
2
|
+
|
|
3
|
+
The public view is the shareable artifact: same score and findings list,
|
|
4
|
+
but target identifier and per-finding evidence are scrubbed. Remediation
|
|
5
|
+
snippets are kept since they're already generic. v0.1 errs on the side of
|
|
6
|
+
hiding more rather than less.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from jinja2 import Environment, select_autoescape
|
|
14
|
+
|
|
15
|
+
from umbermark.core.engine import ScanReport
|
|
16
|
+
from umbermark.core.partial import outside_in
|
|
17
|
+
from umbermark.core.report import _to_dict
|
|
18
|
+
from umbermark.core.wording import present_payload
|
|
19
|
+
|
|
20
|
+
Mode = Literal["owner", "public"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_TEMPLATE = r"""<!doctype html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8">
|
|
27
|
+
<title>Hardening report — {{ target_display }}</title>
|
|
28
|
+
<style>
|
|
29
|
+
:root {
|
|
30
|
+
--bg: #0f1419;
|
|
31
|
+
--panel: #181d24;
|
|
32
|
+
--panel-2: #1f2530;
|
|
33
|
+
--text: #e6e9ef;
|
|
34
|
+
--muted: #9aa3b2;
|
|
35
|
+
--gold: #f5c451;
|
|
36
|
+
--green: #6ec97a;
|
|
37
|
+
--yellow: #e4b15c;
|
|
38
|
+
--red: #ef6b6b;
|
|
39
|
+
--incomplete: #8a90a0;
|
|
40
|
+
--critical: #ef6b6b;
|
|
41
|
+
--high: #f0a050;
|
|
42
|
+
--medium: #e0c060;
|
|
43
|
+
--low: #8bd0c0;
|
|
44
|
+
}
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body {
|
|
47
|
+
margin: 0; padding: 2rem;
|
|
48
|
+
font: 14px/1.5 -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
49
|
+
background: var(--bg); color: var(--text);
|
|
50
|
+
}
|
|
51
|
+
.wrap { max-width: 900px; margin: 0 auto; }
|
|
52
|
+
header { display: flex; align-items: center; gap: 1.5rem; margin-bottom: 2rem; }
|
|
53
|
+
.score {
|
|
54
|
+
width: 7rem; height: 7rem; border-radius: 50%;
|
|
55
|
+
display: flex; align-items: center; justify-content: center;
|
|
56
|
+
flex-direction: column; flex-shrink: 0;
|
|
57
|
+
border: 4px solid var(--{{ band }});
|
|
58
|
+
background: var(--panel);
|
|
59
|
+
}
|
|
60
|
+
.score.partial { border: 3px dashed var(--incomplete); }
|
|
61
|
+
.score .n { font-size: 2.3rem; font-weight: 700; line-height: 1; }
|
|
62
|
+
.score .b { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.2rem; }
|
|
63
|
+
.meta h1 { margin: 0 0 0.4rem; font-size: 1.25rem; font-weight: 600; }
|
|
64
|
+
.meta .target { color: var(--muted); font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|
65
|
+
.meta .timestamp { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
|
|
66
|
+
.coverage-banner {
|
|
67
|
+
background: rgba(138, 144, 160, 0.15);
|
|
68
|
+
border-left: 4px solid var(--incomplete);
|
|
69
|
+
padding: 0.85rem 1.25rem;
|
|
70
|
+
border-radius: 4px;
|
|
71
|
+
margin: 1rem 0 1.5rem;
|
|
72
|
+
color: var(--text);
|
|
73
|
+
}
|
|
74
|
+
.coverage-banner b { color: var(--incomplete); }
|
|
75
|
+
.counts { display: flex; gap: 1rem; margin: 1.5rem 0 2rem; }
|
|
76
|
+
.count {
|
|
77
|
+
background: var(--panel); padding: 0.75rem 1rem; border-radius: 6px;
|
|
78
|
+
flex: 1; text-align: center;
|
|
79
|
+
}
|
|
80
|
+
.count .n { font-size: 1.5rem; font-weight: 700; display: block; }
|
|
81
|
+
.count .l { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
82
|
+
.count.pass .n { color: var(--green); }
|
|
83
|
+
.count.warn .n { color: var(--yellow); }
|
|
84
|
+
.count.fail .n { color: var(--red); }
|
|
85
|
+
.count.skip .n { color: var(--muted); }
|
|
86
|
+
h2 { font-size: 1.05rem; font-weight: 600; margin: 2rem 0 0.75rem; }
|
|
87
|
+
.deductions, .findings { background: var(--panel); border-radius: 8px; overflow: hidden; }
|
|
88
|
+
.row {
|
|
89
|
+
display: grid; grid-template-columns: auto 1fr auto;
|
|
90
|
+
gap: 1rem; align-items: start;
|
|
91
|
+
padding: 1rem 1.25rem;
|
|
92
|
+
border-bottom: 1px solid var(--panel-2);
|
|
93
|
+
}
|
|
94
|
+
.row:last-child { border-bottom: 0; }
|
|
95
|
+
.row .badge {
|
|
96
|
+
display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px;
|
|
97
|
+
font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
|
|
98
|
+
background: var(--panel-2);
|
|
99
|
+
}
|
|
100
|
+
.row .badge.critical { background: var(--critical); color: #0f1419; }
|
|
101
|
+
.row .badge.high { background: var(--high); color: #0f1419; }
|
|
102
|
+
.row .badge.medium { background: var(--medium); color: #0f1419; }
|
|
103
|
+
.row .badge.low { background: var(--low); color: #0f1419; }
|
|
104
|
+
.row .verdict { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
105
|
+
.row .verdict.pass { color: var(--green); }
|
|
106
|
+
.row .verdict.fail { color: var(--red); }
|
|
107
|
+
.row .verdict.warn { color: var(--yellow); }
|
|
108
|
+
.row .title { font-weight: 600; }
|
|
109
|
+
.row .evidence, .row .remediation, .row .skip-reason {
|
|
110
|
+
margin-top: 0.4rem; color: var(--muted); font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem;
|
|
111
|
+
white-space: pre-wrap;
|
|
112
|
+
}
|
|
113
|
+
.row .remediation { color: var(--text); font-family: inherit; white-space: normal; }
|
|
114
|
+
.row .remediation p, .row .remediation ul { margin: 0.3rem 0; }
|
|
115
|
+
.row .remediation pre { margin: 0.3rem 0; padding: 0.5rem 0.75rem; background: var(--panel-2); border-radius: 4px; white-space: pre-wrap; }
|
|
116
|
+
.row .remediation code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|
117
|
+
.row .control { margin-top: 0.2rem; color: var(--muted); font-size: 0.8rem; }
|
|
118
|
+
.points { text-align: right; font-weight: 700; color: var(--red); white-space: nowrap; }
|
|
119
|
+
footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--panel); color: var(--muted); font-size: 0.8rem; text-align: center; }
|
|
120
|
+
footer .cta { color: var(--text); }
|
|
121
|
+
</style>
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<div class="wrap">
|
|
125
|
+
|
|
126
|
+
<header>
|
|
127
|
+
<div class="score{% if partial %} partial{% endif %}">
|
|
128
|
+
{% if partial %}
|
|
129
|
+
<span class="n">{{ partial.score }}</span>
|
|
130
|
+
<span class="b">outside-in</span>
|
|
131
|
+
{% else %}
|
|
132
|
+
{% if band == "incomplete" %}
|
|
133
|
+
<span class="n">?</span>
|
|
134
|
+
{% else %}
|
|
135
|
+
<span class="n">{{ result.score }}</span>
|
|
136
|
+
{% endif %}
|
|
137
|
+
<span class="b">{{ band }}</span>
|
|
138
|
+
{% endif %}
|
|
139
|
+
</div>
|
|
140
|
+
<div class="meta">
|
|
141
|
+
<h1>Linux hardening report</h1>
|
|
142
|
+
<div class="target">{{ target_display }}</div>
|
|
143
|
+
<div class="timestamp">Scanned {{ scanned_at_display }}</div>
|
|
144
|
+
</div>
|
|
145
|
+
</header>
|
|
146
|
+
|
|
147
|
+
{% if partial %}
|
|
148
|
+
<div class="coverage-banner">
|
|
149
|
+
<b>Outside-in score: {{ partial.score }} (partial, {{ partial.ran }} of {{ partial.total }} checks).</b>
|
|
150
|
+
This scan was run from outside without proof of ownership, so only the
|
|
151
|
+
passive checks ran. The number is scored on those checks alone and is not
|
|
152
|
+
the full hardening score. Verify you own the host to run the other
|
|
153
|
+
{{ partial.gated }} check{% if partial.gated != 1 %}s{% endif %}.
|
|
154
|
+
</div>
|
|
155
|
+
{% elif band == "incomplete" %}
|
|
156
|
+
<div class="coverage-banner">
|
|
157
|
+
<b>Incomplete scan.</b>
|
|
158
|
+
Only {{ result.pass_count + result.warn_count + result.fail_count }} of
|
|
159
|
+
{{ result.pass_count + result.warn_count + result.fail_count + result.skip_count }}
|
|
160
|
+
checks were able to run on this target ({{ coverage_pct }}% coverage). The
|
|
161
|
+
score is hidden because there isn't enough signal to call this a clean
|
|
162
|
+
result. See "All findings" below for what got skipped and why.
|
|
163
|
+
</div>
|
|
164
|
+
{% elif result.skip_count > 0 %}
|
|
165
|
+
<div class="coverage-banner" style="border-left-color: var(--muted);">
|
|
166
|
+
{{ coverage_pct }}% scan coverage — {{ result.skip_count }} check{% if result.skip_count != 1 %}s{% endif %} skipped (see findings for reasons).
|
|
167
|
+
</div>
|
|
168
|
+
{% endif %}
|
|
169
|
+
|
|
170
|
+
<section class="counts">
|
|
171
|
+
<div class="count pass"><span class="n">{{ result.pass_count }}</span><span class="l">pass</span></div>
|
|
172
|
+
<div class="count warn"><span class="n">{{ result.warn_count }}</span><span class="l">warn</span></div>
|
|
173
|
+
<div class="count fail"><span class="n">{{ result.fail_count }}</span><span class="l">fail</span></div>
|
|
174
|
+
<div class="count skip"><span class="n">{{ result.skip_count }}</span><span class="l">skip</span></div>
|
|
175
|
+
</section>
|
|
176
|
+
|
|
177
|
+
{% if cost_deductions %}
|
|
178
|
+
<h2>What cost you points</h2>
|
|
179
|
+
<section class="deductions">
|
|
180
|
+
{% for d in cost_deductions %}
|
|
181
|
+
<div class="row">
|
|
182
|
+
<span class="badge {{ d.severity }}">{{ d.severity }}</span>
|
|
183
|
+
<div>
|
|
184
|
+
<div class="title">{{ d.text }}</div>
|
|
185
|
+
<div class="verdict {{ d.verdict }}">{{ d.verdict }}</div>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="points">-{{ d.points_lost }}</div>
|
|
188
|
+
</div>
|
|
189
|
+
{% endfor %}
|
|
190
|
+
</section>
|
|
191
|
+
{% endif %}
|
|
192
|
+
{% if info_deductions %}
|
|
193
|
+
<h2>Informational (no points lost)</h2>
|
|
194
|
+
<section class="deductions">
|
|
195
|
+
{% for d in info_deductions %}
|
|
196
|
+
<div class="row">
|
|
197
|
+
<span class="badge">info</span>
|
|
198
|
+
<div><div class="title">{{ d.text }}</div></div>
|
|
199
|
+
<div></div>
|
|
200
|
+
</div>
|
|
201
|
+
{% endfor %}
|
|
202
|
+
</section>
|
|
203
|
+
{% endif %}
|
|
204
|
+
|
|
205
|
+
<h2>All findings</h2>
|
|
206
|
+
<section class="findings">
|
|
207
|
+
{% for f in findings %}
|
|
208
|
+
<div class="row">
|
|
209
|
+
<span class="badge {{ f.severity }}">{{ f.severity }}</span>
|
|
210
|
+
<div>
|
|
211
|
+
<div class="title">{{ f.text }}</div>
|
|
212
|
+
<div class="verdict {{ f.verdict }}">{{ "info" if f.informational else f.verdict }}</div>
|
|
213
|
+
<div class="control">Check: {{ f.control }}</div>
|
|
214
|
+
{% if mode == 'owner' and f.evidence %}
|
|
215
|
+
<div class="evidence">{{ f.evidence }}</div>
|
|
216
|
+
{% endif %}
|
|
217
|
+
{% if f.remediation %}
|
|
218
|
+
<div class="remediation">{{ f.remediation_html }}</div>
|
|
219
|
+
{% endif %}
|
|
220
|
+
{% if f.verdict == 'skip' %}
|
|
221
|
+
<div class="skip-reason">{{ f.skip_note }}{% if mode == 'owner' and f.skip_reason %} ({{ f.skip_reason }}){% endif %}</div>
|
|
222
|
+
{% endif %}
|
|
223
|
+
</div>
|
|
224
|
+
<div></div>
|
|
225
|
+
</div>
|
|
226
|
+
{% endfor %}
|
|
227
|
+
</section>
|
|
228
|
+
|
|
229
|
+
<footer>
|
|
230
|
+
<span class="cta">Scanned with Umbermark</span>
|
|
231
|
+
{% if mode == 'public' %}
|
|
232
|
+
· Want your own score? Free scan at <tbd-domain>
|
|
233
|
+
{% endif %}
|
|
234
|
+
</footer>
|
|
235
|
+
|
|
236
|
+
</div>
|
|
237
|
+
</body>
|
|
238
|
+
</html>
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class _Ctx:
|
|
244
|
+
target_display: str
|
|
245
|
+
scanned_at_display: str
|
|
246
|
+
band: str
|
|
247
|
+
result: object
|
|
248
|
+
findings: list
|
|
249
|
+
mode: Mode
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _format_timestamp(iso: str) -> str:
|
|
253
|
+
# 2026-05-14T06:58:45.123456+00:00 → 2026-05-14 06:58 UTC
|
|
254
|
+
return iso.split(".")[0].replace("T", " ") + " UTC"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def to_html(report: ScanReport, mode: Mode = "owner") -> str:
|
|
258
|
+
env = Environment(autoescape=select_autoescape(["html", "xml"]))
|
|
259
|
+
tmpl = env.from_string(_TEMPLATE)
|
|
260
|
+
payload = present_payload(_to_dict(report))
|
|
261
|
+
|
|
262
|
+
target = report.target if mode == "owner" else "<redacted>"
|
|
263
|
+
coverage_pct = int(round(report.result.coverage * 100))
|
|
264
|
+
return tmpl.render(
|
|
265
|
+
target_display=target,
|
|
266
|
+
scanned_at_display=_format_timestamp(report.finished_at),
|
|
267
|
+
band=report.result.band,
|
|
268
|
+
partial=None, # a ScanReport rendered here is an on-box scan: always the full score
|
|
269
|
+
coverage_pct=coverage_pct,
|
|
270
|
+
result=payload["result"],
|
|
271
|
+
findings=payload["findings"],
|
|
272
|
+
cost_deductions=payload["cost_deductions"],
|
|
273
|
+
info_deductions=payload["info_deductions"],
|
|
274
|
+
mode=mode,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def render_owner_html_from_payload(
|
|
279
|
+
payload: dict, target: str, mode: Mode = "owner"
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Render the owner-view HTML from the persisted Report v1.0 JSON dict.
|
|
282
|
+
|
|
283
|
+
Used by the PDF render worker, which loads the payload_json from the
|
|
284
|
+
reports table and produces a self-contained HTML document for PDF
|
|
285
|
+
conversion. The template handles dict-shaped values (Jinja falls
|
|
286
|
+
back from .attr to ['attr']) so no reconstruction of dataclasses
|
|
287
|
+
is needed.
|
|
288
|
+
"""
|
|
289
|
+
env = Environment(autoescape=select_autoescape(["html", "xml"]))
|
|
290
|
+
tmpl = env.from_string(_TEMPLATE)
|
|
291
|
+
payload = present_payload(payload)
|
|
292
|
+
result = payload.get("result", {})
|
|
293
|
+
coverage_pct = int(round(float(result.get("coverage", 0.0)) * 100))
|
|
294
|
+
finished = payload.get("finished_at", "")
|
|
295
|
+
return tmpl.render(
|
|
296
|
+
target_display=target,
|
|
297
|
+
scanned_at_display=_format_timestamp(finished) if finished else "",
|
|
298
|
+
band=result.get("band", "incomplete"),
|
|
299
|
+
partial=outside_in(payload), # UMBR-67: unverified outside-in scan -> labelled partial score
|
|
300
|
+
coverage_pct=coverage_pct,
|
|
301
|
+
result=result,
|
|
302
|
+
findings=payload.get("findings", []),
|
|
303
|
+
cost_deductions=payload["cost_deductions"],
|
|
304
|
+
info_deductions=payload["info_deductions"],
|
|
305
|
+
mode=mode,
|
|
306
|
+
)
|
umbermark/core/keyenc.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Authenticated encryption for saved-server SSH private keys.
|
|
2
|
+
|
|
3
|
+
Implements spec §17 — saved-server scans:
|
|
4
|
+
|
|
5
|
+
1. The plaintext key never lives on disk.
|
|
6
|
+
2. Encryption key is derived per user via PBKDF2(KEY_PEPPER || user_id, salt).
|
|
7
|
+
3. SecretBox is XSalsa20-Poly1305 — AEAD; tampering raises on decrypt.
|
|
8
|
+
4. The 16-byte salt is stored in the DB next to the ciphertext.
|
|
9
|
+
|
|
10
|
+
DB-only compromise (no `KEY_PEPPER`) does not yield plaintext keys.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from nacl.secret import SecretBox
|
|
18
|
+
|
|
19
|
+
_SALT_BYTES = 16
|
|
20
|
+
_KDF_ROUNDS = 200_000
|
|
21
|
+
_KEY_BYTES = SecretBox.KEY_SIZE # 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _derive_key(key_pepper: str, user_id: int, salt: bytes) -> bytes:
|
|
25
|
+
if not key_pepper:
|
|
26
|
+
raise ValueError("key_pepper must be non-empty")
|
|
27
|
+
if len(salt) != _SALT_BYTES:
|
|
28
|
+
raise ValueError(f"salt must be {_SALT_BYTES} bytes")
|
|
29
|
+
secret_material = f"{key_pepper}::{user_id}".encode("utf-8")
|
|
30
|
+
return hashlib.pbkdf2_hmac("sha256", secret_material, salt, _KDF_ROUNDS, _KEY_BYTES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encrypt(plaintext: bytes, *, key_pepper: str, user_id: int) -> tuple[bytes, bytes]:
|
|
34
|
+
"""Returns (ciphertext, salt)."""
|
|
35
|
+
salt = os.urandom(_SALT_BYTES)
|
|
36
|
+
key = _derive_key(key_pepper, user_id, salt)
|
|
37
|
+
return SecretBox(key).encrypt(plaintext), salt
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decrypt(ciphertext: bytes, salt: bytes, *, key_pepper: str, user_id: int) -> bytes:
|
|
41
|
+
key = _derive_key(key_pepper, user_id, salt)
|
|
42
|
+
return SecretBox(key).decrypt(ciphertext)
|