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,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
|
+
)
|