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,29 @@
1
+ """Product status label, vendored from starhold-ops `docs/shared/labels.json`.
2
+
3
+ The JSON next to the templates is a byte-for-byte copy of the ops file; the
4
+ label for this product is looked up by key, never typed here.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ LABELS_PATH = Path(__file__).parent.parent / "web" / "labels.json"
13
+ PRODUCT_KEY = "umbermark"
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def _load() -> dict:
18
+ return json.loads(LABELS_PATH.read_text(encoding="utf-8"))
19
+
20
+
21
+ LAUNCHED_LABEL = "Open · paid plans"
22
+
23
+
24
+ def product_label(launched: bool = False) -> str:
25
+ data = _load()
26
+ label = LAUNCHED_LABEL if launched else data["products"][PRODUCT_KEY]
27
+ if label not in data["labels"]:
28
+ raise ValueError(f"label {label!r} is not in the allowed label set")
29
+ return label
@@ -0,0 +1,100 @@
1
+ """Report lifecycle rules shared by the web app and the purge job.
2
+
3
+ - Free anonymous / upload / outside-in reports: 30 days (expires_at set at
4
+ creation from ANONYMOUS_REPORT_TTL_SECONDS).
5
+ - Paid ($5) report: 3 months from payment, or until deleted, whichever is
6
+ first. A report claimed into an account has no expiry (it lives with the
7
+ account).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import calendar
12
+ import datetime as dt
13
+ import logging
14
+ import sqlite3
15
+ from pathlib import Path
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ PAID_RETENTION_MONTHS = 3
20
+ # After this many failed PDF removals the purge job logs an ALERT every run.
21
+ PURGE_ALERT_AFTER = 3
22
+
23
+
24
+ def add_months(moment: dt.datetime, months: int) -> dt.datetime:
25
+ """Calendar-month addition; the day is clamped to the target month's length."""
26
+ index = moment.month - 1 + months
27
+ year = moment.year + index // 12
28
+ month = index % 12 + 1
29
+ day = min(moment.day, calendar.monthrange(year, month)[1])
30
+ return moment.replace(year=year, month=month, day=day)
31
+
32
+
33
+ def paid_expiry(paid_at: dt.datetime) -> str:
34
+ return add_months(paid_at, PAID_RETENTION_MONTHS).isoformat()
35
+
36
+
37
+ def is_expired(
38
+ expires_at: str | None, now: dt.datetime | None = None, *, row_id: object = None
39
+ ) -> bool:
40
+ """True once `expires_at` has passed. None/empty means no expiry.
41
+
42
+ Fails CLOSED: a value that cannot be parsed (or has no timezone) counts as
43
+ expired, so a corrupted row is purged rather than kept forever. Only the row
44
+ id is logged, never the value or any content.
45
+ """
46
+ if not expires_at:
47
+ return False
48
+ try:
49
+ moment = dt.datetime.fromisoformat(expires_at)
50
+ return (now or dt.datetime.now(dt.UTC)) >= moment
51
+ except (ValueError, TypeError):
52
+ log.warning("unparseable expires_at on row %s: treated as expired", row_id)
53
+ return True
54
+
55
+
56
+ def remove_pdf(pdf_dir: str | Path | None, short_id: str) -> bool:
57
+ """Delete the cached PDF for a report if one exists. Never raises.
58
+
59
+ Returns True when no PDF remains (deleted, or none existed) and False when
60
+ an unlink failed, so the caller can keep the row and retry later.
61
+ """
62
+ if not pdf_dir:
63
+ return True
64
+ try:
65
+ path = Path(pdf_dir) / f"{short_id}.pdf"
66
+ # short_id comes from our own rows, but never trust a path component.
67
+ if path.parent == Path(pdf_dir) and path.is_file():
68
+ path.unlink()
69
+ return True
70
+ except OSError:
71
+ return False
72
+
73
+
74
+ def erase_report(conn: sqlite3.Connection, report, pdf_dir: str | Path | None) -> bool:
75
+ """Erase a report: PDF FIRST, then the row (keys, intents, tokens go with it).
76
+
77
+ Returns True when fully erased. If the PDF cannot be removed the row is kept,
78
+ soft-deleted (invisible at once, all its keys and intents revoked) and marked
79
+ `purge_failed_at`; the purge job retries it on every run and logs an ALERT
80
+ after PURGE_ALERT_AFTER failures. Call inside a transaction.
81
+ """
82
+ from umbermark.db.repos import reports as reports_repo
83
+
84
+ if not remove_pdf(pdf_dir, report.short_id):
85
+ now = dt.datetime.now(dt.UTC).isoformat()
86
+ conn.execute("DELETE FROM report_keys WHERE report_id = ?", (report.id,))
87
+ conn.execute("DELETE FROM purchase_intents WHERE report_id = ?", (report.id,))
88
+ conn.execute(
89
+ "UPDATE reports SET deleted_at = COALESCE(deleted_at, ?), purge_failed_at = ?, "
90
+ "purge_failures = purge_failures + 1 WHERE id = ?",
91
+ (now, now, report.id),
92
+ )
93
+ failures = conn.execute("SELECT purge_failures FROM reports WHERE id = ?", (report.id,)).fetchone()[0]
94
+ if failures >= PURGE_ALERT_AFTER:
95
+ log.error("ALERT: PDF removal failed %s times for report row %s", failures, report.id)
96
+ else:
97
+ log.warning("PDF removal failed for report row %s (attempt %s); will retry", report.id, failures)
98
+ return False
99
+ reports_repo.hard_delete(conn, report.id)
100
+ return True
@@ -0,0 +1,55 @@
1
+ """Log redaction: no sign-in token, claim token or key may reach a log line.
2
+
3
+ uvicorn's access log prints the request line, which includes `?token=...` on
4
+ magic-link verification and `/claim/<token>` on claim links. This filter
5
+ rewrites those before the record is emitted. It is installed on the access
6
+ logger at app creation and is cheap enough to attach to the root logger too.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+
13
+ _PATTERNS = (
14
+ (re.compile(r"([?&](?:token|key|ownership_key|delete_key)=)[^&\s\"']+", re.I), r"\1[redacted]"),
15
+ (re.compile(r"(/claim/)[^\s/?\"']+"), r"\1[redacted]"),
16
+ (re.compile(r"(/auth/verify\?token=)[^\s\"']+"), r"\1[redacted]"),
17
+ (re.compile(r"\bumk_[A-Za-z0-9_\-]{8,}"), "umk_[redacted]"),
18
+ )
19
+
20
+
21
+ def redact(text: str) -> str:
22
+ for pattern, repl in _PATTERNS:
23
+ text = pattern.sub(repl, text)
24
+ return text
25
+
26
+
27
+ class RedactFilter(logging.Filter):
28
+ def filter(self, record: logging.LogRecord) -> bool:
29
+ try:
30
+ message = record.getMessage()
31
+ except Exception:
32
+ return True
33
+ cleaned = redact(message)
34
+ if cleaned == message:
35
+ return True
36
+ # Redact inside the args when they are a plain tuple, and keep the tuple. uvicorn's access formatter unpacks
37
+ # exactly five args (client, method, path, version, status); emptying them made it raise inside logging and
38
+ # the whole access line was lost. Anything else (dict args, no args) falls back to a flattened message.
39
+ if isinstance(record.args, tuple) and record.args:
40
+ record.args = tuple(redact(a) if isinstance(a, str) else a for a in record.args)
41
+ try:
42
+ if redact(record.getMessage()) == record.getMessage():
43
+ return True
44
+ except Exception:
45
+ pass
46
+ record.msg = cleaned
47
+ record.args = ()
48
+ return True
49
+
50
+
51
+ def install(logger_names: tuple[str, ...] = ("uvicorn.access", "uvicorn.error", "umbermark")) -> None:
52
+ for name in logger_names:
53
+ logger = logging.getLogger(name)
54
+ if not any(isinstance(f, RedactFilter) for f in logger.filters):
55
+ logger.addFilter(RedactFilter())
@@ -0,0 +1,57 @@
1
+ """Outside-in partial score (UMBR-67).
2
+
3
+ An external scan of a host whose ownership is NOT verified runs the passive
4
+ checks only. Its number is computed from those checks alone, so it is shown as
5
+ a labelled "Outside-in score" with the count of checks that ran, never as the
6
+ full hardening score and never with a band. The stored band stays whatever the
7
+ scoring engine said (normally "incomplete"), so history and deltas keep
8
+ treating it as not comparable with a full score.
9
+
10
+ Pure functions over the stored payload dict: no I/O.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ LABEL = "Outside-in score"
18
+ KIND_PARTIAL = "outside_in_partial"
19
+ KIND_FULL = "hardening"
20
+
21
+
22
+ def outside_in(payload: Any) -> dict[str, int] | None:
23
+ """{'score', 'ran', 'total', 'gated'} for an unverified outside-in scan, else None.
24
+
25
+ None means "show this report the usual way": a verified external scan, an
26
+ on-box scan, a blocked target, or a scan where no check produced a verdict
27
+ (there is nothing to score, so the "?" stays).
28
+ """
29
+ if not isinstance(payload, dict):
30
+ return None
31
+ gate = payload.get("external_gate")
32
+ if not isinstance(gate, dict) or gate.get("verified") or gate.get("blocked"):
33
+ return None
34
+ try:
35
+ gated = int(gate.get("gated_count") or 0)
36
+ result = payload.get("result") or {}
37
+ ran = sum(int(result.get(k) or 0) for k in ("pass_count", "warn_count", "fail_count"))
38
+ total = ran + int(result.get("skip_count") or 0)
39
+ score = int(result["score"])
40
+ except (KeyError, TypeError, ValueError, AttributeError):
41
+ return None
42
+ if gated <= 0 or ran <= 0:
43
+ return None
44
+ return {"score": max(0, min(100, score)), "ran": ran, "total": total, "gated": gated}
45
+
46
+
47
+ def outside_in_json(payload_json: str | None) -> dict[str, int] | None:
48
+ """`outside_in` over a stored payload_json string (bad JSON -> None)."""
49
+ try:
50
+ return outside_in(json.loads(payload_json or ""))
51
+ except ValueError:
52
+ return None
53
+
54
+
55
+ def short_label(p: dict[str, int]) -> str:
56
+ """'Outside-in score 97 (partial, 2 of 7 checks)' for one-line places."""
57
+ return f"{LABEL} {p['score']} (partial, {p['ran']} of {p['total']} checks)"
File without changes
@@ -0,0 +1,26 @@
1
+ """PdfRenderer Protocol + FakePdfRenderer.
2
+
3
+ The Fake returns a bytestring that starts with the PDF magic header so
4
+ downstream code that sniffs for valid PDFs is happy, and embeds the
5
+ original HTML in the body so integration tests can assert on rendered
6
+ content without parsing real PDFs.
7
+
8
+ Real impl lives in weasyprint_renderer.py and is only loaded in
9
+ production / Linux CI.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Protocol
14
+
15
+
16
+ class PdfRenderer(Protocol):
17
+ def render(self, *, html: str, base_url: str) -> bytes: ...
18
+
19
+
20
+ class FakePdfRenderer:
21
+ def __init__(self) -> None:
22
+ self.calls: list[tuple[str, str]] = []
23
+
24
+ def render(self, *, html: str, base_url: str) -> bytes:
25
+ self.calls.append((html, base_url))
26
+ return b"%PDF-1.4\n" + html.encode("utf-8") + b"\n%%EOF\n"
@@ -0,0 +1,18 @@
1
+ """Production PDF renderer using WeasyPrint.
2
+
3
+ Imported lazily — on Windows + macOS WeasyPrint needs GTK + cairo
4
+ system libs that are non-trivial to install. On a Linux container
5
+ (the prod target) WeasyPrint is a clean pip install. Tests that
6
+ need a "real" PDF renderer are marked skip-if-not-installed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+
11
+ class WeasyPrintRenderer:
12
+ def __init__(self) -> None:
13
+ from weasyprint import HTML # noqa: F401
14
+
15
+ def render(self, *, html: str, base_url: str) -> bytes:
16
+ from weasyprint import HTML
17
+
18
+ return HTML(string=html, base_url=base_url).write_pdf()
@@ -0,0 +1,73 @@
1
+ """Distro / platform detection — parses /etc/os-release into a normalized family."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+
7
+ from umbermark.core.ssh import CommandRunner
8
+
9
+ # Map of distro ID (or any token in ID_LIKE) -> family the checks use.
10
+ _FAMILY_BY_ID = {
11
+ "debian": "debian",
12
+ "ubuntu": "debian",
13
+ "linuxmint": "debian",
14
+ "pop": "debian",
15
+ "rhel": "rhel",
16
+ "centos": "rhel",
17
+ "rocky": "rhel",
18
+ "almalinux": "rhel",
19
+ "fedora": "rhel",
20
+ "ol": "rhel",
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class PlatformFacts:
26
+ distro: str = "unknown"
27
+ distro_version: str = "unknown"
28
+ distro_family: str = "unknown"
29
+ arch: str = "unknown"
30
+ is_container: bool = False
31
+ raw: dict[str, str] = field(default_factory=dict)
32
+
33
+
34
+ def _parse_os_release(text: str) -> dict[str, str]:
35
+ out: dict[str, str] = {}
36
+ for line in text.splitlines():
37
+ m = re.match(r"^([A-Z_]+)=(.*)$", line.strip())
38
+ if m:
39
+ key = m.group(1)
40
+ val = m.group(2).strip().strip('"').strip("'")
41
+ out[key] = val
42
+ return out
43
+
44
+
45
+ def _family_for(distro_id: str, id_like: str) -> str:
46
+ if distro_id in _FAMILY_BY_ID:
47
+ return _FAMILY_BY_ID[distro_id]
48
+ for token in id_like.split():
49
+ if token in _FAMILY_BY_ID:
50
+ return _FAMILY_BY_ID[token]
51
+ return "unknown"
52
+
53
+
54
+ def detect_platform(runner: CommandRunner) -> PlatformFacts:
55
+ facts = PlatformFacts()
56
+ r = runner.run("cat /etc/os-release 2>/dev/null", timeout=5)
57
+ if r.ok and r.stdout:
58
+ parsed = _parse_os_release(r.stdout)
59
+ facts.raw = parsed
60
+ facts.distro = parsed.get("ID", "unknown")
61
+ facts.distro_version = parsed.get("VERSION_ID", "unknown")
62
+ facts.distro_family = _family_for(facts.distro, parsed.get("ID_LIKE", ""))
63
+ r2 = runner.run("uname -m 2>/dev/null", timeout=5)
64
+ if r2.ok and r2.stdout:
65
+ facts.arch = r2.stdout.strip()
66
+ # Build the access context now: this issues every detector's collector
67
+ # commands, so capture enumeration (which drives off this function) sees them.
68
+ from umbermark.core.access import maybe_context
69
+
70
+ # Cached on the runner; checks fetch it with get_access_context(runner). It is
71
+ # deliberately NOT stored on PlatformFacts, which is serialised into reports.
72
+ maybe_context(runner)
73
+ return facts
@@ -0,0 +1,75 @@
1
+ """Public-view scrubbing for reports.
2
+
3
+ Spec §12.3: public `/r/<short_id>` shows score + band + counts + deduction
4
+ titles (severity-only) + each finding's title/severity/verdict/generic
5
+ remediation. It MUST NOT include specific evidence, skip_reason details,
6
+ platform_facts, or the unscrubbed target identifier.
7
+
8
+ `redact_report(report, target)` is the canonical entry point. It returns a
9
+ NEW dict (does not mutate the input).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from copy import deepcopy
15
+ from typing import Any
16
+
17
+ from umbermark.core.wording import skip_note
18
+
19
+ _IPV4 = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
20
+ # IPv6 — accepts forms with `::` shorthand (e.g., 2001:db8::1).
21
+ # Boundary lookarounds prevent partial mid-word matches.
22
+ _IPV6 = re.compile(
23
+ r"(?<![\w:])[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{0,4}){2,7}(?![\w:])"
24
+ )
25
+ _ABS_PATH = re.compile(r"(?<![A-Za-z0-9])/[A-Za-z0-9_.][A-Za-z0-9_./-]*")
26
+ # Version: dotted numeric core + optional alphanumeric tail (e.g., 9.2p1-2+deb12u3)
27
+ _VERSION = re.compile(r"\b\d+(?:\.\d+){1,3}[A-Za-z0-9_.+~-]*\b")
28
+
29
+ REDACTED = "<redacted>"
30
+
31
+ # UMBR-20: checks whose evidence is built only from port numbers, bind-scope words and process names (never an
32
+ # address or a path), so the public view can keep it. A finding without its evidence is not actionable. The kept
33
+ # text still goes through redact_evidence as a second line of defence.
34
+ PUBLIC_SAFE_EVIDENCE: frozenset[str] = frozenset({"public_listen_anomalies"})
35
+
36
+
37
+ def redact_evidence(text: str, target: str = "") -> str:
38
+ """Strip identifying tokens from a free-form evidence string.
39
+
40
+ Replaces: the literal target string, IPv4/IPv6 addresses, absolute paths,
41
+ and dotted-version strings. Order matters: target first (so we don't
42
+ accidentally treat the target as a version), then IPs, then paths, then
43
+ versions.
44
+ """
45
+ if not text:
46
+ return text
47
+ result = text
48
+ if target:
49
+ result = result.replace(target, REDACTED)
50
+ result = _IPV4.sub(REDACTED, result)
51
+ result = _IPV6.sub(REDACTED, result)
52
+ result = _ABS_PATH.sub("<path>", result)
53
+ result = _VERSION.sub("<version>", result)
54
+ return result
55
+
56
+
57
+ def redact_report(report: dict[str, Any], target: str) -> dict[str, Any]:
58
+ """Return a scrubbed copy of `report` for public consumption."""
59
+ scrubbed = deepcopy(report)
60
+ scrubbed["target"] = REDACTED
61
+ scrubbed.pop("platform_facts", None)
62
+ findings = scrubbed.get("findings", [])
63
+ for f in findings:
64
+ # Public view drops specific evidence and skip-reason details entirely, except the allow-listed
65
+ # IP-free evidence above (scrubbed again regardless).
66
+ if f.get("check_id") in PUBLIC_SAFE_EVIDENCE and f.get("verdict") in ("warn", "fail"):
67
+ f["evidence"] = redact_evidence(f.get("evidence", ""), target)
68
+ else:
69
+ f["evidence"] = ""
70
+ # Keep a generic, human reason ("needs root", "not applicable") before
71
+ # the raw detail is dropped, so skip rows can still explain themselves.
72
+ if f.get("verdict") == "skip":
73
+ f["skip_note"] = skip_note(f.get("skip_reason", ""))
74
+ f["skip_reason"] = ""
75
+ return scrubbed
@@ -0,0 +1,121 @@
1
+ """Remediation bundle: one reviewable shell file per server, built from its latest scan (UMBR-66; Pro and Agency).
2
+
3
+ Every failing or warning finding that costs points (and is not an accepted risk) becomes one function holding the
4
+ Fix Pack steps for the server's distribution family. Nothing runs by default:
5
+
6
+ bash umbermark-fix-<id>.sh list # what is in the file
7
+ sudo bash umbermark-fix-<id>.sh run ID ... # run the named fixes, each in its own subshell
8
+
9
+ Steps the catalogue marks as needing judgement are included commented out, with a note, so they are never run
10
+ blindly. The snippets are the same reviewed Fix Pack text shown on the report page.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from umbermark.catalogue import get_entry
17
+
18
+ _RHEL = ("rhel", "rocky", "alma", "almalinux", "fedora", "centos", "ol", "oracle")
19
+ _DEBIAN = ("debian", "ubuntu", "raspbian", "linuxmint", "pop")
20
+ _ID_RE = re.compile(r"^[a-z0-9_]{1,64}$")
21
+
22
+
23
+ def family(payload: dict) -> str | None:
24
+ """'debian', 'rhel' or None (unknown: both variants are included, each labelled)."""
25
+ distro = str((payload.get("platform_facts") or {}).get("distro", "")).lower()
26
+ if any(distro.startswith(d) for d in _DEBIAN):
27
+ return "debian"
28
+ if any(distro.startswith(d) for d in _RHEL):
29
+ return "rhel"
30
+ return None
31
+
32
+
33
+ def _comment(text: str) -> str:
34
+ return "\n".join(" # " + line if line else " #" for line in text.splitlines())
35
+
36
+
37
+ def _step_lines(step, fam: str | None) -> list[str]:
38
+ out = [f" # --- {step.title}"]
39
+ picks: list[tuple[str, str]] = []
40
+ if step.any is not None:
41
+ picks.append(("Any distro", step.any))
42
+ if fam in (None, "debian") and step.debian is not None:
43
+ picks.append(("Debian / Ubuntu", step.debian))
44
+ if fam in (None, "rhel") and step.rhel is not None:
45
+ picks.append(("RHEL / Rocky / Alma / Fedora", step.rhel))
46
+ for label, snippet in picks:
47
+ if fam is None and label != "Any distro":
48
+ out.append(f" # {label} only: commented out because the scan could not tell the distribution.")
49
+ out.append(_comment(snippet))
50
+ elif step.judgement:
51
+ out.append(" # Needs your judgement: read it, adapt it, then uncomment.")
52
+ out.append(_comment(snippet))
53
+ else:
54
+ # Not indented: a heredoc terminator inside a snippet must stay at column 0.
55
+ out.extend(snippet.rstrip("\n").splitlines())
56
+ return out
57
+
58
+
59
+ def build_script(*, payload: dict, server_label: str, scanned_at: str, score: int, accepted_ids: set[str]) -> str:
60
+ fam = family(payload)
61
+ lost = {d.get("check_id"): d.get("points_lost", 0) for d in (payload.get("result") or {}).get("deductions", [])}
62
+ blocks: list[tuple[str, str, list[str]]] = []
63
+ for f in payload.get("findings", []) or []:
64
+ cid = f.get("check_id", "")
65
+ if not _ID_RE.match(cid) or str(f.get("verdict")) not in ("fail", "warn") or not lost.get(cid):
66
+ continue
67
+ if cid in accepted_ids:
68
+ continue
69
+ entry = get_entry(cid)
70
+ if entry is None or not entry.fix_steps:
71
+ continue
72
+ lines: list[str] = []
73
+ for step in entry.fix_steps:
74
+ lines.extend(_step_lines(step, fam))
75
+ blocks.append((cid, entry.title.replace("\n", " "), lines))
76
+ label = re.sub(r"[^\w .:@-]", "_", server_label)[:80]
77
+ head = [
78
+ "#!/usr/bin/env bash",
79
+ "# =============================================================================",
80
+ f"# Umbermark remediation bundle for {label}",
81
+ f"# From the scan of {scanned_at[:16].replace('T', ' ')} UTC (score {score}).",
82
+ f"# Distribution family: {fam or 'unknown (distro-specific steps are commented out)'}",
83
+ "#",
84
+ "# REVIEW EVERY BLOCK BEFORE RUNNING. Nothing runs unless you name it:",
85
+ "# bash this-file.sh list",
86
+ "# sudo bash this-file.sh run CHECK_ID [CHECK_ID ...]",
87
+ "# Each fix runs in its own subshell. Accepted risks are left out. Re-scan afterwards to verify.",
88
+ "# =============================================================================",
89
+ "set -uo pipefail",
90
+ "",
91
+ ]
92
+ body: list[str] = []
93
+ for cid, title, lines in blocks:
94
+ body.append(f"fix_{cid}() {{ # {title}")
95
+ body.extend(lines)
96
+ body.append(" :")
97
+ body.append("}")
98
+ body.append("")
99
+ ids = " ".join(cid for cid, _, _ in blocks)
100
+ listing = "\n".join(f" echo ' {cid} {title.replace(chr(39), '')}'" for cid, title, _ in blocks) or " echo ' (none)'"
101
+ tail = [
102
+ 'case "${1:-list}" in',
103
+ " list)",
104
+ f" echo 'Fixes in this file ({len(blocks)}):'",
105
+ listing,
106
+ " ;;",
107
+ " run)",
108
+ " shift",
109
+ ' [ "$#" -gt 0 ] || { echo "name at least one fix: run CHECK_ID ..." >&2; exit 2; }',
110
+ ' for id in "$@"; do',
111
+ ' case " ' + ids + ' " in',
112
+ ' *" $id "*) echo "== $id"; ( "fix_$id" ) || echo "!! $id exited with $?" >&2 ;;',
113
+ ' *) echo "unknown fix: $id (see: list)" >&2; exit 2 ;;',
114
+ " esac",
115
+ " done",
116
+ " ;;",
117
+ ' *) echo "usage: $0 list | run CHECK_ID ..." >&2; exit 2 ;;',
118
+ "esac",
119
+ "",
120
+ ]
121
+ return "\n".join(head + body + tail)
@@ -0,0 +1,25 @@
1
+ """Report serialization — JSON for phase 0; HTML/PDF arrive in phase 2."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import fields, is_dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from umbermark.core.engine import ScanReport
10
+
11
+
12
+ def _to_dict(obj: Any) -> Any:
13
+ if isinstance(obj, Enum):
14
+ return obj.value
15
+ if is_dataclass(obj):
16
+ return {f.name: _to_dict(getattr(obj, f.name)) for f in fields(obj)}
17
+ if isinstance(obj, list):
18
+ return [_to_dict(x) for x in obj]
19
+ if isinstance(obj, dict):
20
+ return {k: _to_dict(v) for k, v in obj.items()}
21
+ return obj
22
+
23
+
24
+ def to_json(report: ScanReport, indent: int | None = 2) -> str:
25
+ return json.dumps(_to_dict(report), indent=indent)