umbermark-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- umbermark/__init__.py +18 -0
- umbermark/checks/__init__.py +88 -0
- umbermark/checks/auto_updates_configured.py +149 -0
- umbermark/checks/dns_resolver_sanity.py +47 -0
- umbermark/checks/fail2ban_enabled.py +67 -0
- umbermark/checks/failed_login_anomaly.py +48 -0
- umbermark/checks/failed_systemd_units.py +41 -0
- umbermark/checks/firewall_protective.py +247 -0
- umbermark/checks/journald_persistence.py +55 -0
- umbermark/checks/kernel_age.py +74 -0
- umbermark/checks/listen_vs_firewall_delta.py +136 -0
- umbermark/checks/mac_framework_enforcing.py +97 -0
- umbermark/checks/pending_security_updates.py +188 -0
- umbermark/checks/public_listen_anomalies.py +200 -0
- umbermark/checks/reboot_pending.py +71 -0
- umbermark/checks/repo_gpg_trust.py +97 -0
- umbermark/checks/root_disk_free.py +46 -0
- umbermark/checks/ssh_non_default_port.py +62 -0
- umbermark/checks/ssh_password_auth.py +71 -0
- umbermark/checks/ssh_permit_root.py +59 -0
- umbermark/checks/ssh_weak_ciphers.py +85 -0
- umbermark/checks/sudo_requires_authentication.py +41 -0
- umbermark/checks/sudoers_nopasswd.py +111 -0
- umbermark/checks/swap_pressure.py +51 -0
- umbermark/checks/time_sync.py +55 -0
- umbermark/checks/tmp_hardening.py +45 -0
- umbermark/checks/user_password_aging.py +95 -0
- umbermark/checks/world_writable_suid_audit.py +171 -0
- umbermark/cli/__init__.py +4 -0
- umbermark/cli/app.py +23 -0
- umbermark/cli/auth.py +44 -0
- umbermark/cli/commands/__init__.py +0 -0
- umbermark/cli/commands/doctor.py +16 -0
- umbermark/cli/commands/login.py +80 -0
- umbermark/cli/commands/push.py +54 -0
- umbermark/cli/commands/report.py +26 -0
- umbermark/cli/commands/scan.py +77 -0
- umbermark/cli/commands/scan_local.py +72 -0
- umbermark/cli/commands/scan_wsl.py +35 -0
- umbermark/cli/commands/version.py +11 -0
- umbermark/cli/config.py +14 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/branding.py +71 -0
- umbermark/core/capture.py +228 -0
- umbermark/core/check.py +82 -0
- umbermark/core/checkin.py +89 -0
- umbermark/core/crypto.py +15 -0
- umbermark/core/duration.py +45 -0
- umbermark/core/engine.py +74 -0
- umbermark/core/evidence.py +83 -0
- umbermark/core/external/__init__.py +6 -0
- umbermark/core/external/check.py +54 -0
- umbermark/core/external/checks/__init__.py +31 -0
- umbermark/core/external/checks/exposed_datastores.py +55 -0
- umbermark/core/external/checks/http_security_headers.py +249 -0
- umbermark/core/external/checks/legacy_services.py +52 -0
- umbermark/core/external/checks/open_management_ports.py +65 -0
- umbermark/core/external/checks/ssh_alt_ports.py +77 -0
- umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
- umbermark/core/external/checks/tls_hygiene.py +107 -0
- umbermark/core/external/engine.py +89 -0
- umbermark/core/external/ownership.py +607 -0
- umbermark/core/external/probe.py +103 -0
- umbermark/core/external/socket_probe.py +400 -0
- umbermark/core/firewall.py +713 -0
- umbermark/core/fleet.py +92 -0
- umbermark/core/history.py +235 -0
- umbermark/core/html_report.py +308 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +55 -0
- umbermark/core/partial.py +57 -0
- umbermark/core/pdf/__init__.py +0 -0
- umbermark/core/pdf/renderer.py +26 -0
- umbermark/core/pdf/weasyprint_renderer.py +18 -0
- umbermark/core/platform.py +73 -0
- umbermark/core/redact.py +75 -0
- umbermark/core/remediation.py +121 -0
- umbermark/core/report.py +25 -0
- umbermark/core/scoring.py +105 -0
- umbermark/core/settings.py +238 -0
- umbermark/core/short_id.py +27 -0
- umbermark/core/signing.py +24 -0
- umbermark/core/ssh.py +156 -0
- umbermark/core/ssh_fingerprint.py +16 -0
- umbermark/core/tiers.py +138 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0.dist-info/RECORD +96 -0
- umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0.dist-info/top_level.txt +1 -0
umbermark/core/labels.py
ADDED
|
@@ -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
|
umbermark/core/redact.py
ADDED
|
@@ -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)
|
umbermark/core/report.py
ADDED
|
@@ -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)
|