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,93 @@
1
+ """External check: weak/deprecated algorithms offered in the SSH handshake.
2
+
3
+ Read entirely from the pre-auth KEXINIT — the public list of algorithms the
4
+ server is willing to negotiate, the same thing ``nmap --script ssh2-enum-algos``
5
+ sees. No login, no credentials. Offering a broken algorithm (SHA-1 key
6
+ exchange, CBC ciphers, MD5/SHA-1 MACs, DSA/ssh-rsa host keys) leaves the
7
+ connection open to downgrade even when stronger options are also advertised.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from umbermark.core.check import Finding, Severity, Verdict
12
+ from umbermark.core.external.check import ExternalCheck
13
+ from umbermark.core.external.probe import ExternalProbe, SshAlgorithms
14
+
15
+
16
+ def _weak_kex(name: str) -> bool:
17
+ # SHA-1 key exchange (incl. the 1024-bit group1) is deprecated.
18
+ return "sha1" in name.lower()
19
+
20
+
21
+ def _weak_cipher(name: str) -> bool:
22
+ n = name.lower()
23
+ return (
24
+ n.endswith("-cbc")
25
+ or n.startswith(("3des", "arcfour", "blowfish", "cast128"))
26
+ or "rc4" in n
27
+ or n == "none"
28
+ )
29
+
30
+
31
+ def _weak_mac(name: str) -> bool:
32
+ n = name.lower()
33
+ return "md5" in n or "sha1" in n or n.startswith("umac-64") or n == "none"
34
+
35
+
36
+ def _weak_host_key(name: str) -> bool:
37
+ # ssh-dss (DSA) is broken; ssh-rsa is the SHA-1 RSA signature OpenSSH
38
+ # disabled by default in 8.8. rsa-sha2-256/512 are the modern replacements.
39
+ return name.lower() in {"ssh-dss", "ssh-rsa"}
40
+
41
+
42
+ def find_weak_algorithms(algos: SshAlgorithms) -> list[tuple[str, str]]:
43
+ """Every (category, name) pair in a KEXINIT offer that is weak/deprecated.
44
+
45
+ Shared by the port-22 check and the alternate-port check (UMBR-38) so both
46
+ apply exactly the same definition of "weak".
47
+ """
48
+ weak: list[tuple[str, str]] = []
49
+ weak += [("KEX", n) for n in algos.kex if _weak_kex(n)]
50
+ weak += [("cipher", n) for n in algos.ciphers if _weak_cipher(n)]
51
+ weak += [("MAC", n) for n in algos.macs if _weak_mac(n)]
52
+ weak += [("host-key", n) for n in algos.host_keys if _weak_host_key(n)]
53
+ return weak
54
+
55
+
56
+ class SshWeakAlgorithmsCheck(ExternalCheck):
57
+ id = "external_ssh_weak_algorithms"
58
+ title = "SSH offers no weak or deprecated algorithms"
59
+ category = "external_network"
60
+ severity = Severity.HIGH
61
+ weight = 12
62
+ remediation = (
63
+ "Pin sshd to modern algorithms only. In sshd_config set KexAlgorithms, "
64
+ "Ciphers, MACs, and HostKeyAlgorithms to a curated modern list "
65
+ "(e.g. curve25519-sha256, chacha20-poly1305@openssh.com / aes256-gcm, "
66
+ "hmac-sha2-512-etm, ssh-ed25519 / rsa-sha2-512). Remove SHA-1 key "
67
+ "exchange, CBC ciphers, MD5/SHA-1 MACs, and the ssh-dss/ssh-rsa host keys."
68
+ )
69
+
70
+ def run(self, probe: ExternalProbe) -> Finding:
71
+ algos = probe.ssh_algorithms(22)
72
+ if algos is None:
73
+ return self._finding(
74
+ Verdict.SKIP,
75
+ skip_reason=(
76
+ "SSH KEXINIT not readable on port 22 "
77
+ "(host unreachable, filtered, or SSH on a non-standard port)"
78
+ ),
79
+ )
80
+ weak = find_weak_algorithms(algos)
81
+ if weak:
82
+ listing = ", ".join(f"{n} ({cat})" for cat, n in weak)
83
+ return self._finding(
84
+ Verdict.FAIL,
85
+ evidence=f"weak/deprecated SSH algorithms offered: {listing}",
86
+ )
87
+ return self._finding(
88
+ Verdict.PASS,
89
+ evidence=(
90
+ "SSH offers only modern algorithms (no SHA-1 key exchange, CBC "
91
+ "ciphers, MD5/SHA-1 MACs, or DSA/ssh-rsa host keys)"
92
+ ),
93
+ )
@@ -0,0 +1,107 @@
1
+ """External check: TLS protocol and certificate hygiene.
2
+
3
+ Everything here is visible from the handshake alone — no authentication. We flag
4
+ deprecated protocol versions still on offer (SSLv2/3, TLS 1.0/1.1), an expired
5
+ or self-signed leaf certificate (hard fail), and a certificate within two weeks
6
+ of expiry (advisory — fix it before it lapses).
7
+
8
+ A legacy version the scanner's own TLS stack could not offer (UMBR-34) is
9
+ "unknown", not "not offered": such a result is never a PASS. Hard failures
10
+ still FAIL and near-expiry still WARNs; otherwise the check SKIPs.
11
+ A certificate that could not be read or parsed (UMBR-35) is a WARN: its expiry
12
+ and issuer are unverified, so it is never a PASS.
13
+ A chain that does not verify against a public trust store (UMBR-40) is a FAIL:
14
+ self-signed and private-CA certificates alike (issuer != subject does not make a
15
+ certificate trusted). If the verifying handshake could not run, trust is unknown
16
+ and the result is a WARN, never a PASS.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from umbermark.core.check import Finding, Severity, Verdict
21
+ from umbermark.core.external.check import ExternalCheck
22
+ from umbermark.core.external.probe import ExternalProbe
23
+
24
+ # TLS/SSL versions that should no longer be offered.
25
+ _LEGACY_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.0", "TLSv1.1"}
26
+ _EXPIRY_WARN_DAYS = 14
27
+
28
+
29
+ class TlsHygieneCheck(ExternalCheck):
30
+ id = "external_tls_hygiene"
31
+ title = "TLS uses modern protocols and a valid certificate"
32
+ category = "external_network"
33
+ severity = Severity.HIGH
34
+ weight = 10
35
+ remediation = (
36
+ "Disable SSLv2/SSLv3/TLS 1.0/1.1 and offer only TLS 1.2+ (prefer 1.3). "
37
+ "Serve a current certificate from a trusted CA (not self-signed) and "
38
+ "renew well before expiry — automate it with ACME/Let's Encrypt."
39
+ )
40
+
41
+ def run(self, probe: ExternalProbe) -> Finding:
42
+ info = probe.tls_info(443)
43
+ if info is None:
44
+ return self._finding(
45
+ Verdict.SKIP,
46
+ skip_reason="no TLS service answered on port 443",
47
+ )
48
+
49
+ hard: list[str] = [] # FAIL-level problems
50
+ legacy = sorted(p for p in info.protocols if p in _LEGACY_PROTOCOLS)
51
+ if legacy:
52
+ hard.append(f"deprecated protocols offered: {', '.join(legacy)}")
53
+ if info.cert_readable:
54
+ if info.days_until_expiry < 0:
55
+ hard.append(f"certificate expired {abs(info.days_until_expiry)} days ago")
56
+ if info.self_signed:
57
+ hard.append("certificate is self-signed (not from a trusted CA)")
58
+ # Trust is judged even when the leaf could not be parsed: the verifying
59
+ # handshake reads the chain independently of our DER parser.
60
+ if info.chain_trusted is False:
61
+ reason = info.trust_error or "verification failed"
62
+ redundant = info.cert_readable and (
63
+ info.self_signed
64
+ or (info.days_until_expiry < 0 and "expired" in reason.lower())
65
+ )
66
+ if not redundant:
67
+ if "local issuer" in reason.lower():
68
+ # Also what a server that omits its intermediate certificate produces:
69
+ # browsers may fill the gap, but curl, Python and many apps fail.
70
+ hard.append(
71
+ "certificate chain is incomplete or not trusted by a public CA "
72
+ f"({reason}; if the certificate is CA-issued, serve the full chain)"
73
+ )
74
+ else:
75
+ hard.append(f"certificate chain is not trusted by a public CA ({reason})")
76
+
77
+ unprobed = sorted(p for p in info.unprobed if p in _LEGACY_PROTOCOLS)
78
+ unknown: list[str] = []
79
+ if not info.cert_readable:
80
+ unknown.append("certificate could not be read or parsed (expiry and issuer unverified)")
81
+ if info.chain_trusted is None:
82
+ unknown.append("certificate chain trust could not be verified")
83
+ if unprobed:
84
+ unknown.append(
85
+ f"could not probe {', '.join(unprobed)} (scanner TLS library cannot offer it)"
86
+ )
87
+
88
+ if hard:
89
+ return self._finding(Verdict.FAIL, evidence="; ".join(hard + unknown))
90
+
91
+ warn: list[str] = []
92
+ if info.cert_readable and 0 <= info.days_until_expiry < _EXPIRY_WARN_DAYS:
93
+ warn.append(f"certificate expires in {info.days_until_expiry} days")
94
+ if warn or not info.cert_readable or info.chain_trusted is None:
95
+ return self._finding(Verdict.WARN, evidence="; ".join(warn + unknown))
96
+
97
+ if unprobed:
98
+ return self._finding(
99
+ Verdict.SKIP,
100
+ skip_reason=f"{unknown[0]}; legacy-protocol support unverified "
101
+ "(certificate valid and not near expiry)",
102
+ )
103
+
104
+ return self._finding(
105
+ Verdict.PASS,
106
+ evidence="modern TLS only, certificate valid and not near expiry",
107
+ )
@@ -0,0 +1,89 @@
1
+ """External scan orchestrator — runs external checks against a network probe.
2
+
3
+ Mirrors `core.engine.run_scan` but for the outside-in probe: no platform
4
+ detection, no distro gating. Reuses `ScanReport` + `scoring.score` so the report
5
+ and rendering are identical to the on-box tiers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from typing import Iterable
11
+
12
+ from umbermark.core.check import Finding, Verdict
13
+ from umbermark.core.engine import ScanReport
14
+ from umbermark.core.external.check import ExternalCheck
15
+ from umbermark.core.external.probe import ExternalProbe
16
+ from umbermark.core.scoring import score
17
+
18
+
19
+ UNVERIFIED_SKIP_REASON = "target ownership not verified"
20
+ IP_CHANGED_SKIP_REASON = "target IP changed since verification; re-verify ownership"
21
+ # Check-specific unverified reasons (set as ExternalCheck.unverified_skip_reason).
22
+ ALT_SSH_UNVERIFIED_SKIP_REASON = "alternate SSH ports not probed (unverified)"
23
+ GATE_SKIP_REASONS = (
24
+ UNVERIFIED_SKIP_REASON,
25
+ IP_CHANGED_SKIP_REASON,
26
+ ALT_SSH_UNVERIFIED_SKIP_REASON,
27
+ )
28
+
29
+
30
+ def run_external_scan(
31
+ target: str,
32
+ probe: ExternalProbe,
33
+ checks: Iterable[ExternalCheck],
34
+ *,
35
+ verified: bool = False,
36
+ unverified_reason: str = UNVERIFIED_SKIP_REASON,
37
+ active_probe: ExternalProbe | None = None,
38
+ ) -> ScanReport:
39
+ """Run ``checks`` against ``probe``.
40
+
41
+ ``verified`` defaults to False (fail closed): ACTIVE checks (port probing)
42
+ are never invoked unless the caller has established target ownership. They
43
+ are reported as SKIP so coverage honestly reflects what was not tested.
44
+ """
45
+ started = datetime.now(timezone.utc).isoformat()
46
+ findings: list[Finding] = []
47
+ for c in checks:
48
+ if c.active and not verified:
49
+ # A caller-supplied reason (e.g. IP changed) is more specific than
50
+ # the check's own wording, so it wins over the per-check default.
51
+ reason = unverified_reason
52
+ if reason == UNVERIFIED_SKIP_REASON and c.unverified_skip_reason:
53
+ reason = c.unverified_skip_reason
54
+ findings.append(
55
+ Finding(
56
+ check_id=c.id,
57
+ title=c.title,
58
+ category=c.category,
59
+ severity=c.severity,
60
+ weight=c.weight,
61
+ verdict=Verdict.SKIP,
62
+ skip_reason=reason,
63
+ )
64
+ )
65
+ continue
66
+ try:
67
+ findings.append(
68
+ c.run(active_probe if c.active and active_probe is not None else probe)
69
+ )
70
+ except Exception as exc:
71
+ findings.append(
72
+ Finding(
73
+ check_id=c.id,
74
+ title=c.title,
75
+ category=c.category,
76
+ severity=c.severity,
77
+ weight=c.weight,
78
+ verdict=Verdict.SKIP,
79
+ skip_reason=f"check raised: {exc!r}",
80
+ )
81
+ )
82
+ finished = datetime.now(timezone.utc).isoformat()
83
+ return ScanReport(
84
+ target=target,
85
+ started_at=started,
86
+ finished_at=finished,
87
+ findings=findings,
88
+ result=score(findings),
89
+ )