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.
Files changed (96) hide show
  1. umbermark/__init__.py +18 -0
  2. umbermark/checks/__init__.py +88 -0
  3. umbermark/checks/auto_updates_configured.py +149 -0
  4. umbermark/checks/dns_resolver_sanity.py +47 -0
  5. umbermark/checks/fail2ban_enabled.py +67 -0
  6. umbermark/checks/failed_login_anomaly.py +48 -0
  7. umbermark/checks/failed_systemd_units.py +41 -0
  8. umbermark/checks/firewall_protective.py +247 -0
  9. umbermark/checks/journald_persistence.py +55 -0
  10. umbermark/checks/kernel_age.py +74 -0
  11. umbermark/checks/listen_vs_firewall_delta.py +136 -0
  12. umbermark/checks/mac_framework_enforcing.py +97 -0
  13. umbermark/checks/pending_security_updates.py +188 -0
  14. umbermark/checks/public_listen_anomalies.py +200 -0
  15. umbermark/checks/reboot_pending.py +71 -0
  16. umbermark/checks/repo_gpg_trust.py +97 -0
  17. umbermark/checks/root_disk_free.py +46 -0
  18. umbermark/checks/ssh_non_default_port.py +62 -0
  19. umbermark/checks/ssh_password_auth.py +71 -0
  20. umbermark/checks/ssh_permit_root.py +59 -0
  21. umbermark/checks/ssh_weak_ciphers.py +85 -0
  22. umbermark/checks/sudo_requires_authentication.py +41 -0
  23. umbermark/checks/sudoers_nopasswd.py +111 -0
  24. umbermark/checks/swap_pressure.py +51 -0
  25. umbermark/checks/time_sync.py +55 -0
  26. umbermark/checks/tmp_hardening.py +45 -0
  27. umbermark/checks/user_password_aging.py +95 -0
  28. umbermark/checks/world_writable_suid_audit.py +171 -0
  29. umbermark/cli/__init__.py +4 -0
  30. umbermark/cli/app.py +23 -0
  31. umbermark/cli/auth.py +44 -0
  32. umbermark/cli/commands/__init__.py +0 -0
  33. umbermark/cli/commands/doctor.py +16 -0
  34. umbermark/cli/commands/login.py +80 -0
  35. umbermark/cli/commands/push.py +54 -0
  36. umbermark/cli/commands/report.py +26 -0
  37. umbermark/cli/commands/scan.py +77 -0
  38. umbermark/cli/commands/scan_local.py +72 -0
  39. umbermark/cli/commands/scan_wsl.py +35 -0
  40. umbermark/cli/commands/version.py +11 -0
  41. umbermark/cli/config.py +14 -0
  42. umbermark/core/__init__.py +0 -0
  43. umbermark/core/accepted.py +61 -0
  44. umbermark/core/access.py +896 -0
  45. umbermark/core/branding.py +71 -0
  46. umbermark/core/capture.py +228 -0
  47. umbermark/core/check.py +82 -0
  48. umbermark/core/checkin.py +89 -0
  49. umbermark/core/crypto.py +15 -0
  50. umbermark/core/duration.py +45 -0
  51. umbermark/core/engine.py +74 -0
  52. umbermark/core/evidence.py +83 -0
  53. umbermark/core/external/__init__.py +6 -0
  54. umbermark/core/external/check.py +54 -0
  55. umbermark/core/external/checks/__init__.py +31 -0
  56. umbermark/core/external/checks/exposed_datastores.py +55 -0
  57. umbermark/core/external/checks/http_security_headers.py +249 -0
  58. umbermark/core/external/checks/legacy_services.py +52 -0
  59. umbermark/core/external/checks/open_management_ports.py +65 -0
  60. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  61. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  62. umbermark/core/external/checks/tls_hygiene.py +107 -0
  63. umbermark/core/external/engine.py +89 -0
  64. umbermark/core/external/ownership.py +607 -0
  65. umbermark/core/external/probe.py +103 -0
  66. umbermark/core/external/socket_probe.py +400 -0
  67. umbermark/core/firewall.py +713 -0
  68. umbermark/core/fleet.py +92 -0
  69. umbermark/core/history.py +235 -0
  70. umbermark/core/html_report.py +308 -0
  71. umbermark/core/keyenc.py +42 -0
  72. umbermark/core/labels.py +29 -0
  73. umbermark/core/lifecycle.py +100 -0
  74. umbermark/core/logredact.py +55 -0
  75. umbermark/core/partial.py +57 -0
  76. umbermark/core/pdf/__init__.py +0 -0
  77. umbermark/core/pdf/renderer.py +26 -0
  78. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  79. umbermark/core/platform.py +73 -0
  80. umbermark/core/redact.py +75 -0
  81. umbermark/core/remediation.py +121 -0
  82. umbermark/core/report.py +25 -0
  83. umbermark/core/scoring.py +105 -0
  84. umbermark/core/settings.py +238 -0
  85. umbermark/core/short_id.py +27 -0
  86. umbermark/core/signing.py +24 -0
  87. umbermark/core/ssh.py +156 -0
  88. umbermark/core/ssh_fingerprint.py +16 -0
  89. umbermark/core/tiers.py +138 -0
  90. umbermark/core/webhooks.py +177 -0
  91. umbermark/core/wording.py +371 -0
  92. umbermark_cli-1.0.0.dist-info/METADATA +11 -0
  93. umbermark_cli-1.0.0.dist-info/RECORD +96 -0
  94. umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
  95. umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
  96. umbermark_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -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,308 @@
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
+ {% if brand %}<div class="brand" style="border-left: 4px solid {{ brand.accent }}; padding-left: 8px; margin-bottom: 6px;">{% if brand.logo %}<img src="{{ brand.logo }}" alt="" style="max-height: 40px; vertical-align: middle; margin-right: 8px;">{% endif %}<strong>Prepared by {{ brand.name }}</strong></div>{% endif %}
142
+ <h1>Linux hardening report</h1>
143
+ <div class="target">{{ target_display }}</div>
144
+ <div class="timestamp">Scanned {{ scanned_at_display }}</div>
145
+ </div>
146
+ </header>
147
+
148
+ {% if partial %}
149
+ <div class="coverage-banner">
150
+ <b>Outside-in score: {{ partial.score }} (partial, {{ partial.ran }} of {{ partial.total }} checks).</b>
151
+ This scan was run from outside without proof of ownership, so only the
152
+ passive checks ran. The number is scored on those checks alone and is not
153
+ the full hardening score. Verify you own the host to run the other
154
+ {{ partial.gated }} check{% if partial.gated != 1 %}s{% endif %}.
155
+ </div>
156
+ {% elif band == "incomplete" %}
157
+ <div class="coverage-banner">
158
+ <b>Incomplete scan.</b>
159
+ Only {{ result.pass_count + result.warn_count + result.fail_count }} of
160
+ {{ result.pass_count + result.warn_count + result.fail_count + result.skip_count }}
161
+ checks were able to run on this target ({{ coverage_pct }}% coverage). The
162
+ score is hidden because there isn't enough signal to call this a clean
163
+ result. See "All findings" below for what got skipped and why.
164
+ </div>
165
+ {% elif result.skip_count > 0 %}
166
+ <div class="coverage-banner" style="border-left-color: var(--muted);">
167
+ {{ coverage_pct }}% scan coverage — {{ result.skip_count }} check{% if result.skip_count != 1 %}s{% endif %} skipped (see findings for reasons).
168
+ </div>
169
+ {% endif %}
170
+
171
+ <section class="counts">
172
+ <div class="count pass"><span class="n">{{ result.pass_count }}</span><span class="l">pass</span></div>
173
+ <div class="count warn"><span class="n">{{ result.warn_count }}</span><span class="l">warn</span></div>
174
+ <div class="count fail"><span class="n">{{ result.fail_count }}</span><span class="l">fail</span></div>
175
+ <div class="count skip"><span class="n">{{ result.skip_count }}</span><span class="l">skip</span></div>
176
+ </section>
177
+
178
+ {% if cost_deductions %}
179
+ <h2>What cost you points</h2>
180
+ <section class="deductions">
181
+ {% for d in cost_deductions %}
182
+ <div class="row">
183
+ <span class="badge {{ d.severity }}">{{ d.severity }}</span>
184
+ <div>
185
+ <div class="title">{{ d.text }}</div>
186
+ <div class="verdict {{ d.verdict }}">{{ d.verdict }}</div>
187
+ </div>
188
+ <div class="points">-{{ d.points_lost }}</div>
189
+ </div>
190
+ {% endfor %}
191
+ </section>
192
+ {% endif %}
193
+ {% if info_deductions %}
194
+ <h2>Informational (no points lost)</h2>
195
+ <section class="deductions">
196
+ {% for d in info_deductions %}
197
+ <div class="row">
198
+ <span class="badge">info</span>
199
+ <div><div class="title">{{ d.text }}</div></div>
200
+ <div></div>
201
+ </div>
202
+ {% endfor %}
203
+ </section>
204
+ {% endif %}
205
+
206
+ <h2>All findings</h2>
207
+ <section class="findings">
208
+ {% for f in findings %}
209
+ <div class="row">
210
+ <span class="badge {{ f.severity }}">{{ f.severity }}</span>
211
+ <div>
212
+ <div class="title">{{ f.text }}</div>
213
+ <div class="verdict {{ f.verdict }}">{{ "info" if f.informational else f.verdict }}</div>
214
+ <div class="control">Check: {{ f.control }}</div>
215
+ {% if mode == 'owner' and f.evidence %}
216
+ <div class="evidence">{{ f.evidence }}</div>
217
+ {% endif %}
218
+ {% if f.remediation %}
219
+ <div class="remediation">{{ f.remediation_html }}</div>
220
+ {% endif %}
221
+ {% if f.verdict == 'skip' %}
222
+ <div class="skip-reason">{{ f.skip_note }}{% if mode == 'owner' and f.skip_reason %} ({{ f.skip_reason }}){% endif %}</div>
223
+ {% endif %}
224
+ </div>
225
+ <div></div>
226
+ </div>
227
+ {% endfor %}
228
+ </section>
229
+
230
+ <footer>
231
+ <span class="cta">Scanned with Umbermark</span>
232
+ {% if mode == 'public' %}
233
+ &nbsp;·&nbsp; Want your own score? Free scan at <tbd-domain>
234
+ {% endif %}
235
+ </footer>
236
+
237
+ </div>
238
+ </body>
239
+ </html>
240
+ """
241
+
242
+
243
+ @dataclass
244
+ class _Ctx:
245
+ target_display: str
246
+ scanned_at_display: str
247
+ band: str
248
+ result: object
249
+ findings: list
250
+ mode: Mode
251
+
252
+
253
+ def _format_timestamp(iso: str) -> str:
254
+ # 2026-05-14T06:58:45.123456+00:00 → 2026-05-14 06:58 UTC
255
+ return iso.split(".")[0].replace("T", " ") + " UTC"
256
+
257
+
258
+ def to_html(report: ScanReport, mode: Mode = "owner") -> str:
259
+ env = Environment(autoescape=select_autoescape(["html", "xml"]))
260
+ tmpl = env.from_string(_TEMPLATE)
261
+ payload = present_payload(_to_dict(report))
262
+
263
+ target = report.target if mode == "owner" else "<redacted>"
264
+ coverage_pct = int(round(report.result.coverage * 100))
265
+ return tmpl.render(
266
+ target_display=target,
267
+ scanned_at_display=_format_timestamp(report.finished_at),
268
+ band=report.result.band,
269
+ partial=None, # a ScanReport rendered here is an on-box scan: always the full score
270
+ coverage_pct=coverage_pct,
271
+ result=payload["result"],
272
+ findings=payload["findings"],
273
+ cost_deductions=payload["cost_deductions"],
274
+ info_deductions=payload["info_deductions"],
275
+ mode=mode,
276
+ )
277
+
278
+
279
+ def render_owner_html_from_payload(
280
+ payload: dict, target: str, mode: Mode = "owner", brand: dict | None = None
281
+ ) -> str:
282
+ """Render the owner-view HTML from the persisted Report v1.0 JSON dict.
283
+
284
+ Used by the PDF render worker, which loads the payload_json from the
285
+ reports table and produces a self-contained HTML document for PDF
286
+ conversion. The template handles dict-shaped values (Jinja falls
287
+ back from .attr to ['attr']) so no reconstruction of dataclasses
288
+ is needed.
289
+ """
290
+ env = Environment(autoescape=select_autoescape(["html", "xml"]))
291
+ tmpl = env.from_string(_TEMPLATE)
292
+ payload = present_payload(payload)
293
+ result = payload.get("result", {})
294
+ coverage_pct = int(round(float(result.get("coverage", 0.0)) * 100))
295
+ finished = payload.get("finished_at", "")
296
+ return tmpl.render(
297
+ target_display=target,
298
+ scanned_at_display=_format_timestamp(finished) if finished else "",
299
+ band=result.get("band", "incomplete"),
300
+ partial=outside_in(payload), # UMBR-67: unverified outside-in scan -> labelled partial score
301
+ coverage_pct=coverage_pct,
302
+ result=result,
303
+ findings=payload.get("findings", []),
304
+ cost_deductions=payload["cost_deductions"],
305
+ info_deductions=payload["info_deductions"],
306
+ mode=mode,
307
+ brand=brand, # UMBR-66 white-label (Agency): {name, accent (#rrggbb, validated), logo (PNG data: URI)}
308
+ )
@@ -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)