umbermark-cli 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. umbermark/__init__.py +3 -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 +13 -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/capture.py +228 -0
  46. umbermark/core/check.py +82 -0
  47. umbermark/core/checkin.py +89 -0
  48. umbermark/core/crypto.py +15 -0
  49. umbermark/core/duration.py +45 -0
  50. umbermark/core/engine.py +74 -0
  51. umbermark/core/external/__init__.py +6 -0
  52. umbermark/core/external/check.py +54 -0
  53. umbermark/core/external/checks/__init__.py +31 -0
  54. umbermark/core/external/checks/exposed_datastores.py +55 -0
  55. umbermark/core/external/checks/http_security_headers.py +249 -0
  56. umbermark/core/external/checks/legacy_services.py +52 -0
  57. umbermark/core/external/checks/open_management_ports.py +65 -0
  58. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  59. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  60. umbermark/core/external/checks/tls_hygiene.py +107 -0
  61. umbermark/core/external/engine.py +89 -0
  62. umbermark/core/external/ownership.py +607 -0
  63. umbermark/core/external/probe.py +103 -0
  64. umbermark/core/external/socket_probe.py +400 -0
  65. umbermark/core/firewall.py +713 -0
  66. umbermark/core/fleet.py +92 -0
  67. umbermark/core/history.py +235 -0
  68. umbermark/core/html_report.py +306 -0
  69. umbermark/core/keyenc.py +42 -0
  70. umbermark/core/labels.py +29 -0
  71. umbermark/core/lifecycle.py +100 -0
  72. umbermark/core/logredact.py +44 -0
  73. umbermark/core/partial.py +57 -0
  74. umbermark/core/pdf/__init__.py +0 -0
  75. umbermark/core/pdf/renderer.py +26 -0
  76. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  77. umbermark/core/platform.py +73 -0
  78. umbermark/core/redact.py +75 -0
  79. umbermark/core/report.py +25 -0
  80. umbermark/core/scoring.py +105 -0
  81. umbermark/core/settings.py +238 -0
  82. umbermark/core/short_id.py +27 -0
  83. umbermark/core/signing.py +24 -0
  84. umbermark/core/ssh.py +156 -0
  85. umbermark/core/ssh_fingerprint.py +16 -0
  86. umbermark/core/tiers.py +128 -0
  87. umbermark/core/webhooks.py +177 -0
  88. umbermark/core/wording.py +371 -0
  89. umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
  90. umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
  91. umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  92. umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  93. umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,171 @@
1
+ """Audit world-writable files (outside /tmp) + non-standard SUID binaries.
2
+
3
+ Both are well-known privilege-escalation vectors. World-writable files in
4
+ unexpected places are usually misconfigurations; SUID binaries outside the
5
+ distro's standard set deserve scrutiny because each one is a potential
6
+ local exploit if it has bugs.
7
+
8
+ Each find is wrapped in `timeout 20` (in the command string, so the Tier-2
9
+ collector script inherits it); on timeout, partial output is kept and the
10
+ evidence is marked "partial — timed out". On a busy server the whole-tree
11
+ scan can take a while. Output is capped at 100 / 200 lines respectively.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from umbermark.core.check import Check, Finding, Severity, Verdict
16
+ from umbermark.core.ssh import CommandRunner
17
+
18
+ # Standard SUID binaries on Debian/Ubuntu + RHEL family. Anything outside this
19
+ # set is flagged as "non-standard" and worth a manual look — it might be
20
+ # legitimate (e.g. a custom tool), but the operator should know it's there.
21
+ _EXPECTED_SUID_ALLOWLIST = {
22
+ # core identity / auth
23
+ "/usr/bin/sudo", "/usr/bin/su", "/usr/bin/passwd",
24
+ "/usr/bin/chsh", "/usr/bin/chfn", "/usr/bin/newgrp", "/usr/bin/gpasswd",
25
+ "/usr/bin/expiry",
26
+ # rootless-container helpers (the `uidmap` package; required by rootless podman)
27
+ "/usr/bin/newuidmap", "/usr/bin/newgidmap",
28
+ "/bin/newuidmap", "/bin/newgidmap",
29
+ # mount / unmount (often setuid for non-root)
30
+ "/usr/bin/mount", "/usr/bin/umount",
31
+ "/usr/bin/fusermount", "/usr/bin/fusermount3",
32
+ "/bin/mount", "/bin/umount", "/bin/fusermount",
33
+ # network utilities
34
+ "/usr/bin/ping", "/bin/ping",
35
+ # legacy utilities
36
+ "/usr/bin/wall", "/usr/bin/write", "/usr/bin/at", "/usr/bin/crontab",
37
+ # ssh keysign + dbus / polkit
38
+ "/usr/lib/openssh/ssh-keysign",
39
+ "/usr/lib/policykit-1/polkit-agent-helper-1",
40
+ "/usr/lib/polkit-1/polkit-agent-helper-1",
41
+ "/usr/lib/dbus-1.0/dbus-daemon-launch-helper",
42
+ "/usr/libexec/dbus-1/dbus-daemon-launch-helper",
43
+ "/usr/libexec/polkit-agent-helper-1",
44
+ # RHEL/Rocky-specific
45
+ "/usr/sbin/pam_timestamp_check", "/usr/sbin/unix_chkpwd",
46
+ "/usr/sbin/userhelper",
47
+ "/usr/sbin/mount.nfs",
48
+ # mail
49
+ "/usr/sbin/exim4",
50
+ "/usr/sbin/postdrop", "/usr/sbin/postqueue",
51
+ "/usr/sbin/sendmail.postfix",
52
+ # browsers / containers
53
+ "/opt/google/chrome/chrome-sandbox",
54
+ "/usr/lib/chromium/chrome-sandbox",
55
+ "/usr/lib/firefox/firefox", # rare but shipped on some distros
56
+ # snap
57
+ "/snap/core/current/usr/lib/snapd/snap-confine",
58
+ "/usr/lib/snapd/snap-confine",
59
+ # vmware / parallels guest tools (common on cloud VMs)
60
+ "/usr/lib/vmware-tools/sbin/vmware-checkvm",
61
+ }
62
+
63
+
64
+ # SUID files inside rootless-container image layers are owned by sub-uids (not
65
+ # host root) and live in a user namespace, so they give no host escalation.
66
+ _ROOTLESS_CONTAINER_STORAGE = "/.local/share/containers/storage/"
67
+
68
+
69
+ class WorldWritableSuidAuditCheck(Check):
70
+ id = "world_writable_suid_audit"
71
+ title = "World-writable files + non-standard SUID binaries audit"
72
+ category = "filesystem"
73
+ severity = Severity.HIGH
74
+ weight = 9
75
+ applicable_distros = ()
76
+ remediation = (
77
+ "Investigate each entry:\n"
78
+ " World-writable outside /tmp + /var/tmp + /dev/shm is usually a\n"
79
+ " misconfiguration — fix with: chmod o-w <path>\n"
80
+ " SUID binaries not on the standard distro set are potential local\n"
81
+ " privilege-escalation surface. If you didn't intentionally install\n"
82
+ " them, strip the SUID bit: chmod u-s <path>\n"
83
+ " If a non-standard SUID IS intentional (custom helper, third-party\n"
84
+ " software), that's fine — the report is informational."
85
+ )
86
+
87
+ # Each find is bounded by coreutils `timeout` INSIDE the command string, so
88
+ # the Tier-2 collector script (generated from these strings) inherits the
89
+ # bound -- it has no other timeout. The pipeline's exit status is `head`'s,
90
+ # so a `find` killed by `timeout` (exit 124) would be invisible; the brace
91
+ # group therefore echoes a marker line when that happens. `sh -c` keeps the
92
+ # command a single simple command so the runner's `sudo -n ` prefix applies
93
+ # to the whole thing. Patterns use double quotes to nest in the outer '...'.
94
+ _FIND_TIMEOUT_SECS = 20
95
+ _TIMEOUT_MARKER = "__UMBERMARK_FIND_TIMEOUT__"
96
+ _FIND_WW = (
97
+ "sh -c '{ timeout 20 find / -xdev -type f -perm -2 "
98
+ '! -path "/tmp/*" ! -path "/var/tmp/*" ! -path "/dev/shm/*" '
99
+ '! -path "/proc/*" ! -path "/sys/*" 2>/dev/null; '
100
+ "[ $? -eq 124 ] && echo __UMBERMARK_FIND_TIMEOUT__; } | head -100'"
101
+ )
102
+ _FIND_SUID = (
103
+ "sh -c '{ timeout 20 find / -xdev -type f -perm -4000 "
104
+ '! -path "/proc/*" ! -path "/sys/*" 2>/dev/null; '
105
+ "[ $? -eq 124 ] && echo __UMBERMARK_FIND_TIMEOUT__; } | head -200'"
106
+ )
107
+
108
+ def _parse(self, res) -> tuple[list[str], bool, bool]:
109
+ """Return (lines, timed_out, usable) for one find result.
110
+
111
+ Timed out = marker line in stdout, or the runner itself reported exit
112
+ 124. Usable = clean exit, or a timeout that still left partial output.
113
+ """
114
+ raw = [ln.strip() for ln in (res.stdout or "").splitlines() if ln.strip()]
115
+ lines = [ln for ln in raw if ln != self._TIMEOUT_MARKER]
116
+ timed_out = len(lines) != len(raw) or res.exit_code == 124
117
+ usable = res.ok or (res.exit_code == 124 and bool(lines))
118
+ return lines, timed_out, usable
119
+
120
+ def run(self, runner: CommandRunner) -> Finding:
121
+ rww = runner.run(self._FIND_WW, sudo=True, timeout=30)
122
+ rsu = runner.run(self._FIND_SUID, sudo=True, timeout=30)
123
+ # Pipeline exit is head's (0) even on permission denied or no matches,
124
+ # so "usable" = clean exit, or timeout (124) that left partial output.
125
+ ww_lines, ww_to, ww_ok = self._parse(rww)
126
+ suid_lines, suid_to, suid_ok = self._parse(rsu)
127
+ if not (ww_ok or suid_ok):
128
+ return self._finding(
129
+ Verdict.SKIP,
130
+ skip_reason="find produced no output (likely timeout — see --timeout flag)",
131
+ )
132
+ partial = ww_to or suid_to
133
+ if partial and not (ww_lines or suid_lines):
134
+ return self._finding(
135
+ Verdict.SKIP,
136
+ skip_reason="find timed out before producing any results",
137
+ )
138
+ note = " [partial — timed out]" if partial else ""
139
+ container_suid = [s for s in suid_lines if _ROOTLESS_CONTAINER_STORAGE in s]
140
+ unexpected_suid = [
141
+ s for s in suid_lines
142
+ if s not in _EXPECTED_SUID_ALLOWLIST and _ROOTLESS_CONTAINER_STORAGE not in s
143
+ ]
144
+ if container_suid:
145
+ note += f" [{len(container_suid)} SUID file(s) in rootless container storage ignored]"
146
+ problems = len(ww_lines) + len(unexpected_suid)
147
+
148
+ if problems == 0:
149
+ return self._finding(
150
+ Verdict.PASS,
151
+ evidence=(
152
+ f"0 unexpected world-writable, 0 non-standard SUID "
153
+ f"(of {len(suid_lines)} total SUID — all on allow-list or in rootless container storage)"
154
+ f"{note}"
155
+ ),
156
+ )
157
+ parts: list[str] = []
158
+ if ww_lines:
159
+ parts.append(
160
+ f"{len(ww_lines)} world-writable: {', '.join(ww_lines[:3])}"
161
+ + (f" (+{len(ww_lines) - 3} more)" if len(ww_lines) > 3 else "")
162
+ )
163
+ if unexpected_suid:
164
+ parts.append(
165
+ f"{len(unexpected_suid)} non-standard SUID: {', '.join(unexpected_suid[:3])}"
166
+ + (f" (+{len(unexpected_suid) - 3} more)" if len(unexpected_suid) > 3 else "")
167
+ )
168
+ evidence = "; ".join(parts) + note
169
+ if problems <= 5:
170
+ return self._finding(Verdict.WARN, evidence=evidence)
171
+ return self._finding(Verdict.FAIL, evidence=evidence)
@@ -0,0 +1,4 @@
1
+ """Umbermark CLI package."""
2
+ from umbermark.cli.app import app
3
+
4
+ __all__ = ["app"]
umbermark/cli/app.py ADDED
@@ -0,0 +1,23 @@
1
+ """Umbermark CLI (Typer)."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from umbermark.cli.commands import doctor as doctor_cmd
7
+ from umbermark.cli.commands import login as login_cmd
8
+ from umbermark.cli.commands import push as push_cmd
9
+ from umbermark.cli.commands import report as report_cmd
10
+ from umbermark.cli.commands import scan as scan_cmd
11
+ from umbermark.cli.commands import scan_local as scan_local_cmd
12
+ from umbermark.cli.commands import scan_wsl as scan_wsl_cmd
13
+ from umbermark.cli.commands import version as version_cmd
14
+
15
+ app = typer.Typer(name="umbermark", help="Linux server hardening scanner.")
16
+ app.command(name="version")(version_cmd.version)
17
+ app.command(name="doctor")(doctor_cmd.doctor)
18
+ app.command(name="login")(login_cmd.login)
19
+ app.command(name="push")(push_cmd.push)
20
+ app.command(name="scan")(scan_cmd.scan)
21
+ app.command(name="scan-wsl")(scan_wsl_cmd.scan_wsl)
22
+ app.command(name="scan-local")(scan_local_cmd.scan_local)
23
+ app.command(name="report")(report_cmd.report)
umbermark/cli/auth.py ADDED
@@ -0,0 +1,44 @@
1
+ """CLI token storage.
2
+
3
+ Token is the API bearer token issued by /cli/login/callback. Stored
4
+ at ~/.config/umbermark/token on POSIX, %APPDATA%\\umbermark\\token on
5
+ Windows. File is created with 0o600 perms on POSIX.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ def default_token_path() -> Path:
15
+ if sys.platform == "win32":
16
+ base = os.environ.get("APPDATA")
17
+ if base:
18
+ return Path(base) / "umbermark" / "token"
19
+ return Path.home() / "AppData" / "Roaming" / "umbermark" / "token"
20
+ xdg = os.environ.get("XDG_CONFIG_HOME")
21
+ base = Path(xdg) if xdg else Path.home() / ".config"
22
+ return base / "umbermark" / "token"
23
+
24
+
25
+ def save_token(token: str, *, token_path: Path | None = None) -> Path:
26
+ p = token_path or default_token_path()
27
+ p.parent.mkdir(parents=True, exist_ok=True)
28
+ p.write_text(token, encoding="utf-8")
29
+ if sys.platform != "win32":
30
+ p.chmod(0o600)
31
+ return p
32
+
33
+
34
+ def load_token(*, token_path: Path | None = None) -> str | None:
35
+ p = token_path or default_token_path()
36
+ if not p.exists():
37
+ return None
38
+ return p.read_text(encoding="utf-8").strip() or None
39
+
40
+
41
+ def clear_token(*, token_path: Path | None = None) -> None:
42
+ p = token_path or default_token_path()
43
+ if p.exists():
44
+ p.unlink()
File without changes
@@ -0,0 +1,16 @@
1
+ """`umbermark doctor` — environment + token sanity check."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ from umbermark.cli.auth import default_token_path, load_token
7
+ from umbermark.cli.config import resolve_api_url
8
+
9
+
10
+ def doctor() -> None:
11
+ """Print CLI environment state (api URL, token presence)."""
12
+ api = resolve_api_url(flag=None)
13
+ typer.echo(f"api_url: {api}")
14
+ token_path = default_token_path()
15
+ typer.echo(f"token_path: {token_path}")
16
+ typer.echo(f"token_present: {load_token() is not None}")
@@ -0,0 +1,80 @@
1
+ """`umbermark login` — browser-based token exchange.
2
+
3
+ Binds 127.0.0.1:<random port>, opens browser to /cli/login/start, blocks
4
+ until the browser hits the loopback callback with a token, saves it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import secrets
9
+ import threading
10
+ import urllib.parse
11
+ import webbrowser
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from umbermark.cli.auth import save_token
18
+ from umbermark.cli.config import resolve_api_url
19
+
20
+
21
+ def run_login_flow(
22
+ *,
23
+ api_url: str,
24
+ token_path: Path | None = None,
25
+ timeout_seconds: int = 120,
26
+ ) -> None:
27
+ state = secrets.token_urlsafe(16)
28
+ captured: dict = {}
29
+
30
+ class Handler(BaseHTTPRequestHandler):
31
+ def log_message(self, *args, **kwargs): # silence stderr
32
+ return
33
+
34
+ def do_GET(self):
35
+ parsed = urllib.parse.urlparse(self.path)
36
+ qs = urllib.parse.parse_qs(parsed.query)
37
+ captured["token"] = (qs.get("token") or [""])[0]
38
+ captured["state"] = (qs.get("state") or [""])[0]
39
+ done = (qs.get("done_url") or [""])[0]
40
+ if done:
41
+ self.send_response(302)
42
+ self.send_header("Location", done)
43
+ self.end_headers()
44
+ else:
45
+ self.send_response(200)
46
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
47
+ self.end_headers()
48
+ self.wfile.write(b"You can close this tab.")
49
+
50
+ server = HTTPServer(("127.0.0.1", 0), Handler)
51
+ port = server.server_address[1]
52
+ server_thread = threading.Thread(target=server.handle_request, daemon=True)
53
+ server_thread.start()
54
+
55
+ cb = f"http://127.0.0.1:{port}/cb"
56
+ url = f"{api_url}/cli/login/start?" + urllib.parse.urlencode(
57
+ {"cb": cb, "state": state}
58
+ )
59
+ webbrowser.open(url)
60
+
61
+ server_thread.join(timeout=timeout_seconds)
62
+ server.server_close()
63
+
64
+ if not captured.get("token"):
65
+ raise RuntimeError("Login timed out before browser callback completed.")
66
+ if captured.get("state") != state:
67
+ raise RuntimeError("CLI login state mismatch — possible CSRF.")
68
+ save_token(captured["token"], token_path=token_path)
69
+
70
+
71
+ def login(
72
+ api_url: str = typer.Option(
73
+ None, "--api-url", help="Override umbermark API base URL"
74
+ ),
75
+ ) -> None:
76
+ """Open a browser and store the issued API token."""
77
+ resolved = resolve_api_url(flag=api_url)
78
+ typer.echo(f"Opening {resolved} in your browser...")
79
+ run_login_flow(api_url=resolved)
80
+ typer.echo("Saved API token. You can now run `umbermark push`.")
@@ -0,0 +1,54 @@
1
+ """`umbermark push REPORT.json` — upload a local report to the SaaS."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from umbermark.cli.auth import load_token
12
+ from umbermark.cli.config import resolve_api_url
13
+
14
+
15
+ def _make_client(*, base_url: str) -> httpx.Client:
16
+ return httpx.Client(base_url=base_url, timeout=30.0)
17
+
18
+
19
+ def push(
20
+ report: str = typer.Argument(
21
+ ..., help="Path to a Report-v1.0 JSON file (or - for stdin)"
22
+ ),
23
+ api_url: str = typer.Option(None, "--api-url"),
24
+ token_path: str | None = typer.Option(None, "--token-file", hidden=True),
25
+ ) -> None:
26
+ """Upload a local scan report to the SaaS."""
27
+ api = resolve_api_url(flag=api_url)
28
+ tok = load_token(token_path=Path(token_path) if token_path else None)
29
+ if not tok:
30
+ typer.secho(
31
+ "Not logged in. Run `umbermark login` first.", err=True, fg="red"
32
+ )
33
+ raise typer.Exit(code=2)
34
+
35
+ if report == "-":
36
+ payload = json.load(sys.stdin)
37
+ else:
38
+ payload = json.loads(Path(report).read_text(encoding="utf-8"))
39
+
40
+ with _make_client(base_url=api) as c:
41
+ r = c.post(
42
+ "/api/push",
43
+ json=payload,
44
+ headers={"Authorization": f"Bearer {tok}"},
45
+ )
46
+ if r.status_code >= 400:
47
+ typer.secho(
48
+ f"Push failed: HTTP {r.status_code} - {r.text}",
49
+ err=True,
50
+ fg="red",
51
+ )
52
+ raise typer.Exit(code=1)
53
+ body = r.json()
54
+ typer.echo(f"Pushed. {body['url']}")
@@ -0,0 +1,26 @@
1
+ """`umbermark report REPORT.json` — render a local Report-v1.0 JSON as HTML."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from umbermark.core.html_report import render_owner_html_from_payload
11
+
12
+
13
+ def report(
14
+ report_path: str = typer.Argument(..., help="Path to a Report-v1.0 JSON file"),
15
+ output: str | None = typer.Option(
16
+ None, "-o", "--output", help="Write HTML to file (default: stdout)"
17
+ ),
18
+ ) -> None:
19
+ """Render a Report-v1.0 JSON to HTML."""
20
+ payload = json.loads(Path(report_path).read_text(encoding="utf-8"))
21
+ target = payload.get("target", "<unknown>")
22
+ html = render_owner_html_from_payload(payload, target=target)
23
+ if output:
24
+ Path(output).write_text(html, encoding="utf-8")
25
+ else:
26
+ sys.stdout.write(html)
@@ -0,0 +1,77 @@
1
+ """`umbermark scan HOST --user U --key PATH` — SSH scan emitting Report-v1.0 JSON."""
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ import umbermark
11
+ from umbermark.checks import ALL_CHECKS
12
+ from umbermark.core.engine import run_scan
13
+
14
+
15
+ def _build_runner(host: str, user: str, key_path: str):
16
+ """Construct a ParamikoRunner. Test-injectable via monkeypatch."""
17
+ from umbermark.core.ssh import ParamikoRunner
18
+
19
+ return ParamikoRunner(host=host, user=user, key_filename=key_path)
20
+
21
+
22
+ def _enum_value(v):
23
+ return getattr(v, "value", v)
24
+
25
+
26
+ def _report_to_payload(report) -> dict:
27
+ return {
28
+ "schema_version": "1.0",
29
+ "target": report.target,
30
+ "started_at": report.started_at,
31
+ "finished_at": report.finished_at,
32
+ "umbermark_version": umbermark.__version__,
33
+ "findings": [
34
+ {
35
+ "check_id": f.check_id,
36
+ "title": f.title,
37
+ "category": f.category,
38
+ "severity": _enum_value(f.severity),
39
+ "weight": f.weight,
40
+ "verdict": _enum_value(f.verdict),
41
+ "evidence": f.evidence,
42
+ "remediation": f.remediation,
43
+ "skip_reason": f.skip_reason,
44
+ }
45
+ for f in report.findings
46
+ ],
47
+ "result": _deductions_to_dict(report.result),
48
+ }
49
+
50
+
51
+ def _deductions_to_dict(result) -> dict:
52
+ d = dataclasses.asdict(result)
53
+ # Normalize Enum-bearing deduction fields to strings
54
+ for ded in d.get("deductions", []):
55
+ for k in ("severity", "verdict"):
56
+ if k in ded:
57
+ ded[k] = _enum_value(ded[k])
58
+ return d
59
+
60
+
61
+ def scan(
62
+ target: str = typer.Argument(..., help="Host or IP to scan via SSH"),
63
+ user: str = typer.Option(..., "--user", "-u"),
64
+ key: str = typer.Option(..., "--key", "-k"),
65
+ output: str | None = typer.Option(
66
+ None, "-o", "--output", help="Write JSON to file (default: stdout)"
67
+ ),
68
+ ) -> None:
69
+ """Scan a target server via SSH and emit a Report-v1.0 JSON."""
70
+ runner = _build_runner(target, user, key)
71
+ report = run_scan(target=target, runner=runner, checks=ALL_CHECKS)
72
+ payload = _report_to_payload(report)
73
+ text = json.dumps(payload, indent=2)
74
+ if output:
75
+ Path(output).write_text(text, encoding="utf-8")
76
+ else:
77
+ typer.echo(text)
@@ -0,0 +1,72 @@
1
+ """`umbermark scan-local` — scan the local machine via subprocess.
2
+
3
+ Used in the spec §6.4 "agent mode" flow: a Linux box runs the CLI on
4
+ itself, then `umbermark push` uploads the JSON to the SaaS without ever
5
+ exposing SSH credentials.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from umbermark.checks import ALL_CHECKS
16
+ from umbermark.cli.commands.scan import _report_to_payload
17
+ from umbermark.core.engine import run_scan
18
+ from umbermark.core.ssh import CommandResult
19
+
20
+
21
+ class LocalRunner:
22
+ """Run commands in the local shell via subprocess.
23
+
24
+ Mirrors the CommandRunner Protocol so the core scan engine can use
25
+ it interchangeably with ParamikoRunner / WSLRunner / FakeRunner.
26
+ """
27
+
28
+ def __init__(self, shell: str = "/bin/sh") -> None:
29
+ self.shell = shell
30
+
31
+ def run(
32
+ self, command: str, sudo: bool = False, timeout: float = 30.0
33
+ ) -> CommandResult:
34
+ full = f"sudo -n {command}" if sudo else command
35
+ try:
36
+ p = subprocess.run(
37
+ [self.shell, "-c", full],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=timeout,
41
+ encoding="utf-8",
42
+ errors="replace",
43
+ )
44
+ return CommandResult(stdout=p.stdout, stderr=p.stderr, exit_code=p.returncode)
45
+ except subprocess.TimeoutExpired:
46
+ return CommandResult(stdout="", stderr="timeout", exit_code=124)
47
+ except FileNotFoundError:
48
+ return CommandResult(stdout="", stderr="shell not found", exit_code=127)
49
+
50
+ def close(self) -> None:
51
+ return None
52
+
53
+
54
+ def _build_runner():
55
+ return LocalRunner()
56
+
57
+
58
+ def scan_local(
59
+ output: str | None = typer.Option(None, "-o", "--output"),
60
+ ) -> None:
61
+ """Scan the local machine and emit a Report-v1.0 JSON."""
62
+ import socket
63
+
64
+ runner = _build_runner()
65
+ target = socket.gethostname() or "localhost"
66
+ report = run_scan(target=target, runner=runner, checks=ALL_CHECKS)
67
+ payload = _report_to_payload(report)
68
+ text = json.dumps(payload, indent=2)
69
+ if output:
70
+ Path(output).write_text(text, encoding="utf-8")
71
+ else:
72
+ typer.echo(text)
@@ -0,0 +1,35 @@
1
+ """`umbermark scan-wsl [DISTRO]` — scan a WSL distro on this Windows box."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from umbermark.checks import ALL_CHECKS
10
+ from umbermark.cli.commands.scan import _report_to_payload
11
+ from umbermark.core.engine import run_scan
12
+
13
+
14
+ def _build_runner(distro: str | None):
15
+ from umbermark.core.ssh import WSLRunner
16
+
17
+ return WSLRunner(distro=distro)
18
+
19
+
20
+ def scan_wsl(
21
+ distro: str | None = typer.Argument(
22
+ None, help="WSL distro name (omit for default)"
23
+ ),
24
+ output: str | None = typer.Option(None, "-o", "--output"),
25
+ ) -> None:
26
+ """Scan a WSL distro and emit a Report-v1.0 JSON."""
27
+ runner = _build_runner(distro)
28
+ target = f"wsl://{distro or 'default'}"
29
+ report = run_scan(target=target, runner=runner, checks=ALL_CHECKS)
30
+ payload = _report_to_payload(report)
31
+ text = json.dumps(payload, indent=2)
32
+ if output:
33
+ Path(output).write_text(text, encoding="utf-8")
34
+ else:
35
+ typer.echo(text)
@@ -0,0 +1,11 @@
1
+ """`umbermark version` — print installed version."""
2
+ from __future__ import annotations
3
+
4
+ import typer
5
+
6
+ import umbermark
7
+
8
+
9
+ def version() -> None:
10
+ """Print the installed umbermark version."""
11
+ typer.echo(f"umbermark {umbermark.__version__}")
@@ -0,0 +1,13 @@
1
+ """CLI configuration resolver."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Mapping
6
+
7
+ DEFAULT_API_URL = "https://umbermark.starhold.app"
8
+
9
+
10
+ def resolve_api_url(*, flag: str | None, env: Mapping[str, str] | None = None) -> str:
11
+ e = env if env is not None else os.environ
12
+ val = flag or e.get("UMBERMARK_API_URL") or DEFAULT_API_URL
13
+ return val.rstrip("/")
File without changes