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