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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Evidence pack for one owner report (UMBR-66; Pro and Agency): a ZIP an auditor can read without Umbermark.
|
|
2
|
+
|
|
3
|
+
README.txt what the pack is, the server, scan time, score (and score with accepted risks), mapping note
|
|
4
|
+
findings.csv one row per check: verdict, severity, points lost, CIS Controls v8 safeguards, evidence,
|
|
5
|
+
accepted-risk reason and expiry
|
|
6
|
+
cis_controls.csv one row per safeguard: the checks that give evidence for it and the worst verdict among them
|
|
7
|
+
report.json the stored report, as scanned
|
|
8
|
+
|
|
9
|
+
The owner's own data only; built on request, never stored. CSV cells starting with = + - @ are quote-prefixed so a
|
|
10
|
+
spreadsheet does not run them as formulas.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import zipfile
|
|
18
|
+
|
|
19
|
+
from umbermark.catalogue import cis
|
|
20
|
+
|
|
21
|
+
_ORDER = {"fail": 0, "warn": 1, "pass": 2, "skip": 3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe(v):
|
|
25
|
+
return "'" + v if isinstance(v, str) and v[:1] in "=+-@" else v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _csv(rows: list[dict]) -> str:
|
|
29
|
+
buf = io.StringIO()
|
|
30
|
+
if rows:
|
|
31
|
+
w = csv.DictWriter(buf, fieldnames=list(rows[0].keys()))
|
|
32
|
+
w.writeheader()
|
|
33
|
+
for r in rows:
|
|
34
|
+
w.writerow({k: _safe(v) for k, v in r.items()})
|
|
35
|
+
return buf.getvalue()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build(*, report_row, payload: dict, server_label: str | None, accepted: dict, adjusted) -> bytes:
|
|
39
|
+
findings = payload.get("findings", []) or []
|
|
40
|
+
lost = {d.get("check_id"): d.get("points_lost", 0) for d in (payload.get("result") or {}).get("deductions", [])}
|
|
41
|
+
rows = []
|
|
42
|
+
for f in findings:
|
|
43
|
+
cid = f.get("check_id", "")
|
|
44
|
+
verdict = str(f.get("verdict", ""))
|
|
45
|
+
acc = accepted.get(cid)
|
|
46
|
+
rows.append({
|
|
47
|
+
"check_id": cid, "title": f.get("title", ""), "verdict": verdict, "severity": f.get("severity", ""),
|
|
48
|
+
"points_lost": lost.get(cid, 0), "cis_controls_v8": cis.label(cid),
|
|
49
|
+
"evidence": f.get("evidence", "") or f.get("skip_reason", ""),
|
|
50
|
+
"accepted_until": acc["expires_at"][:10] if acc else "", "accepted_reason": acc["reason"] if acc else "",
|
|
51
|
+
})
|
|
52
|
+
by_sg: dict[str, list[tuple[str, str]]] = {}
|
|
53
|
+
for r in rows:
|
|
54
|
+
for sg in cis.CHECK_SAFEGUARDS.get(r["check_id"], ()):
|
|
55
|
+
by_sg.setdefault(sg, []).append((r["check_id"], r["verdict"]))
|
|
56
|
+
sg_rows = [{
|
|
57
|
+
"safeguard": sg, "title": cis.SAFEGUARDS[sg],
|
|
58
|
+
"checks": " ".join(c for c, _ in items),
|
|
59
|
+
"worst_verdict": min((v for _, v in items), key=lambda v: _ORDER.get(v, 9)),
|
|
60
|
+
} for sg, items in sorted(by_sg.items(), key=lambda kv: [int(x) for x in kv[0].split(".")])]
|
|
61
|
+
readme = "\n".join([
|
|
62
|
+
"Umbermark evidence pack",
|
|
63
|
+
"",
|
|
64
|
+
f"Server: {server_label or report_row.target_display}",
|
|
65
|
+
f"Target: {report_row.target_display}",
|
|
66
|
+
f"Scanned: {report_row.created_at}",
|
|
67
|
+
f"Report id: {report_row.id}",
|
|
68
|
+
f"Score: {report_row.score}/100 ({report_row.band}), coverage {round(report_row.coverage * 100)}%",
|
|
69
|
+
(f"Score with accepted risks: {adjusted.score}/100 ({adjusted.band}); accepted: {', '.join(adjusted.accepted)}"
|
|
70
|
+
if adjusted else "Accepted risks: none applied"),
|
|
71
|
+
"",
|
|
72
|
+
"Files: findings.csv (one row per check), cis_controls.csv (one row per safeguard), report.json (as scanned).",
|
|
73
|
+
f"Framework: {cis.FRAMEWORK}. {cis.NOTE}",
|
|
74
|
+
"Verdicts: pass = the check found the expected state; warn and fail cost points; skip = not assessed.",
|
|
75
|
+
"",
|
|
76
|
+
])
|
|
77
|
+
buf = io.BytesIO()
|
|
78
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
79
|
+
z.writestr("README.txt", readme)
|
|
80
|
+
z.writestr("findings.csv", _csv(rows))
|
|
81
|
+
z.writestr("cis_controls.csv", _csv(sg_rows))
|
|
82
|
+
z.writestr("report.json", json.dumps(payload, indent=2, sort_keys=True, default=str))
|
|
83
|
+
return buf.getvalue()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Tier-1 external (outside-in) scanner.
|
|
2
|
+
|
|
3
|
+
Probes a host's public surface from Umbermark infra using only a hostname/IP —
|
|
4
|
+
no credentials, no access. Mirrors the on-box engine but swaps the CommandRunner
|
|
5
|
+
for a network `ExternalProbe`, reusing the same Finding / scoring / ScanReport.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Base class for external (outside-in) checks.
|
|
2
|
+
|
|
3
|
+
Reuses the on-box `Finding` / `Severity` / `Verdict` model so external findings
|
|
4
|
+
flow through the same scoring and report rendering — only the input (a network
|
|
5
|
+
`ExternalProbe` instead of a `CommandRunner`) differs.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExternalCheck(ABC):
|
|
19
|
+
id: str = ""
|
|
20
|
+
title: str = ""
|
|
21
|
+
category: str = "external"
|
|
22
|
+
severity: Severity = Severity.MEDIUM
|
|
23
|
+
weight: int = 5
|
|
24
|
+
remediation: str = ""
|
|
25
|
+
# ACTIVE checks probe ports beyond what a browser/client would touch
|
|
26
|
+
# (data stores, legacy services, management sweeps). The engine refuses to
|
|
27
|
+
# run them unless the target's ownership has been verified (UMBR-26).
|
|
28
|
+
# PASSIVE checks (TLS on 443, HTTP headers, SSH KEXINIT on 22) stay False.
|
|
29
|
+
active: bool = False
|
|
30
|
+
# Optional check-specific SKIP reason shown when the ownership gate stops an
|
|
31
|
+
# ACTIVE check (UMBR-38). Empty = use the engine's generic reason.
|
|
32
|
+
unverified_skip_reason: str = ""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def run(self, probe: "ExternalProbe") -> Finding:
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def _finding(
|
|
39
|
+
self,
|
|
40
|
+
verdict: Verdict,
|
|
41
|
+
evidence: str = "",
|
|
42
|
+
skip_reason: str = "",
|
|
43
|
+
) -> Finding:
|
|
44
|
+
return Finding(
|
|
45
|
+
check_id=self.id,
|
|
46
|
+
title=self.title,
|
|
47
|
+
category=self.category,
|
|
48
|
+
severity=self.severity,
|
|
49
|
+
weight=self.weight,
|
|
50
|
+
verdict=verdict,
|
|
51
|
+
evidence=evidence,
|
|
52
|
+
remediation=self.remediation if verdict in (Verdict.FAIL, Verdict.WARN) else "",
|
|
53
|
+
skip_reason=skip_reason,
|
|
54
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""External check catalog (Tier-1). Grows one slice at a time."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from umbermark.core.external.checks.exposed_datastores import ExposedDataStoresCheck
|
|
5
|
+
from umbermark.core.external.checks.legacy_services import LegacyPlaintextServicesCheck
|
|
6
|
+
from umbermark.core.external.checks.http_security_headers import HttpSecurityHeadersCheck
|
|
7
|
+
from umbermark.core.external.checks.open_management_ports import OpenManagementPortsCheck
|
|
8
|
+
from umbermark.core.external.checks.ssh_alt_ports import SshAltPortsCheck
|
|
9
|
+
from umbermark.core.external.checks.ssh_weak_algorithms import SshWeakAlgorithmsCheck
|
|
10
|
+
from umbermark.core.external.checks.tls_hygiene import TlsHygieneCheck
|
|
11
|
+
|
|
12
|
+
ALL_EXTERNAL_CHECKS = [
|
|
13
|
+
ExposedDataStoresCheck(),
|
|
14
|
+
LegacyPlaintextServicesCheck(),
|
|
15
|
+
OpenManagementPortsCheck(),
|
|
16
|
+
SshWeakAlgorithmsCheck(),
|
|
17
|
+
SshAltPortsCheck(),
|
|
18
|
+
TlsHygieneCheck(),
|
|
19
|
+
HttpSecurityHeadersCheck(),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ALL_EXTERNAL_CHECKS",
|
|
24
|
+
"ExposedDataStoresCheck",
|
|
25
|
+
"LegacyPlaintextServicesCheck",
|
|
26
|
+
"OpenManagementPortsCheck",
|
|
27
|
+
"SshWeakAlgorithmsCheck",
|
|
28
|
+
"SshAltPortsCheck",
|
|
29
|
+
"TlsHygieneCheck",
|
|
30
|
+
"HttpSecurityHeadersCheck",
|
|
31
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""External check: data stores reachable from the internet.
|
|
2
|
+
|
|
3
|
+
A database/cache directly exposed to the public internet is one of the highest-
|
|
4
|
+
signal outside-in findings — it routinely leads to data theft or ransom. We only
|
|
5
|
+
test whether the port answers; we never authenticate or read data.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
10
|
+
from umbermark.core.external.check import ExternalCheck
|
|
11
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
12
|
+
|
|
13
|
+
# Common data-store ports that should never be open to the internet.
|
|
14
|
+
_DATASTORES = {
|
|
15
|
+
6379: "Redis",
|
|
16
|
+
5432: "PostgreSQL",
|
|
17
|
+
3306: "MySQL/MariaDB",
|
|
18
|
+
27017: "MongoDB",
|
|
19
|
+
9200: "Elasticsearch",
|
|
20
|
+
11211: "Memcached",
|
|
21
|
+
9000: "ClickHouse",
|
|
22
|
+
5984: "CouchDB",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExposedDataStoresCheck(ExternalCheck):
|
|
27
|
+
active = True # port probing: ownership-verified targets only (UMBR-26)
|
|
28
|
+
id = "external_exposed_datastores"
|
|
29
|
+
title = "Data stores not exposed to the internet"
|
|
30
|
+
category = "external_network"
|
|
31
|
+
severity = Severity.CRITICAL
|
|
32
|
+
weight = 25
|
|
33
|
+
remediation = (
|
|
34
|
+
"Bind data stores to localhost or a private network and block their "
|
|
35
|
+
"ports at the host/edge firewall. Never expose Redis, PostgreSQL, MySQL, "
|
|
36
|
+
"MongoDB, Elasticsearch, or Memcached directly to the internet; put them "
|
|
37
|
+
"behind your application, a VPN, or an SSH tunnel."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def run(self, probe: ExternalProbe) -> Finding:
|
|
41
|
+
exposed: list[tuple[int, str]] = []
|
|
42
|
+
for port, name in _DATASTORES.items():
|
|
43
|
+
try:
|
|
44
|
+
if probe.tcp_connect(port):
|
|
45
|
+
exposed.append((port, name))
|
|
46
|
+
except Exception:
|
|
47
|
+
continue # a probe error for one port shouldn't sink the check
|
|
48
|
+
if exposed:
|
|
49
|
+
listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
|
|
50
|
+
return self._finding(
|
|
51
|
+
Verdict.FAIL, evidence=f"reachable from the internet: {listing}"
|
|
52
|
+
)
|
|
53
|
+
return self._finding(
|
|
54
|
+
Verdict.PASS, evidence="no common data-store ports reachable from the internet"
|
|
55
|
+
)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""External check: HTTP security response headers.
|
|
2
|
+
|
|
3
|
+
A single unauthenticated request to the site's HTTPS endpoint. Missing hardening
|
|
4
|
+
headers don't breach anything on their own — they remove defenses against
|
|
5
|
+
downgrade, clickjacking, and MIME-sniffing — so this is advisory (WARN, medium).
|
|
6
|
+
|
|
7
|
+
A header that is present but set to a useless value (HSTS max-age=0, a CSP that
|
|
8
|
+
allows any script, a bogus X-Frame-Options) gives no protection, so it is
|
|
9
|
+
reported the same way as a missing one.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
16
|
+
from umbermark.core.external.check import ExternalCheck
|
|
17
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
18
|
+
|
|
19
|
+
# Header -> what it defends against, in report-friendly order.
|
|
20
|
+
_EXPECTED = {
|
|
21
|
+
"strict-transport-security": "forces HTTPS (HSTS)",
|
|
22
|
+
"content-security-policy": "limits script/content sources (CSP)",
|
|
23
|
+
"x-frame-options": "blocks clickjacking",
|
|
24
|
+
"x-content-type-options": "stops MIME-sniffing",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# HSTS shorter than 6 months (Mozilla Observatory's floor, 15768000 s) is only a
|
|
28
|
+
# note, not a WARN: Umbermark's own fix starts at max-age=300 on purpose, so a
|
|
29
|
+
# mistake fixes itself, and tells the user to raise it once the site is stable.
|
|
30
|
+
_MIN_HSTS_MAX_AGE = 15768000
|
|
31
|
+
# Anchored to a directive start so "xmax-age=..." is not read as max-age.
|
|
32
|
+
_MAX_AGE_RE = re.compile(r"(?:^|;)\s*max-age\s*=\s*\"?(\d+)\"?(?=\s*(?:;|,|$))",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
# Host-source with a bare "*" host: "*", "https://*", "http://*:443", "*:443".
|
|
36
|
+
# Also a wildcard TLD ("https://*.com"): "*." plus a single label allows a whole TLD.
|
|
37
|
+
_ANY_HOST_RE = re.compile(
|
|
38
|
+
r"^(?:[a-z][a-z0-9+.-]*:)?(?://)?\*(?:\.[a-z0-9-]+)?(?::|/|$)"
|
|
39
|
+
)
|
|
40
|
+
_ANY_SCHEME = {"http:", "https:", "data:", "blob:", "filesystem:"}
|
|
41
|
+
_INTEGRITY_RE = re.compile(r"'(?:nonce|sha256|sha384|sha512)-[A-Za-z0-9+/_=-]+'")
|
|
42
|
+
_XFO_OK = {"deny", "sameorigin"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _first_header(value: str) -> str:
|
|
46
|
+
"""First of several repeated headers (joined with ',').
|
|
47
|
+
|
|
48
|
+
RFC 6797 s8.1: a UA processes only the first Strict-Transport-Security
|
|
49
|
+
header and ignores the rest, so only that one is judged.
|
|
50
|
+
"""
|
|
51
|
+
return value.split(",")[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _weak_hsts(value: str) -> str | None:
|
|
55
|
+
found = _MAX_AGE_RE.findall(_first_header(value))
|
|
56
|
+
if not found:
|
|
57
|
+
return "no valid max-age"
|
|
58
|
+
if len(found) > 1:
|
|
59
|
+
# RFC 6797 s6.1: each directive once; browsers ignore a non-conforming header.
|
|
60
|
+
return "max-age is repeated, so browsers ignore the header"
|
|
61
|
+
if int(found[0]) == 0:
|
|
62
|
+
return "max-age=0 switches HSTS off"
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _hsts_note(value: str) -> str | None:
|
|
67
|
+
"""Advisory (not WARN) note for a sound but short HSTS max-age."""
|
|
68
|
+
found = _MAX_AGE_RE.findall(_first_header(value))
|
|
69
|
+
if len(found) == 1 and 0 < int(found[0]) < _MIN_HSTS_MAX_AGE:
|
|
70
|
+
return (f"note: Strict-Transport-Security max-age={int(found[0])} is short; "
|
|
71
|
+
"raise to >=6 months (15768000) when stable")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_UNRESOLVED = "no script-src/default-src"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _csp_policy(policy: str) -> tuple[bool, str | None]:
|
|
79
|
+
"""Judge one CSP policy. Returns (has_script_policy, weakness)."""
|
|
80
|
+
directives: dict[str, list[str]] = {}
|
|
81
|
+
for part in policy.split(";"):
|
|
82
|
+
tokens = part.strip().split()
|
|
83
|
+
if tokens:
|
|
84
|
+
# First occurrence of a directive wins, per the CSP spec.
|
|
85
|
+
directives.setdefault(tokens[0].lower(), [t.lower() for t in tokens[1:]])
|
|
86
|
+
# CSP3 fallback chains differ per use, so judge each separately:
|
|
87
|
+
# <script> elements : script-src-elem -> script-src -> default-src
|
|
88
|
+
# inline handlers : script-src-attr -> script-src -> default-src
|
|
89
|
+
# eval()/string code: script-src -> default-src (never the -elem/-attr forms)
|
|
90
|
+
def pick(*names: str) -> list[str] | None:
|
|
91
|
+
for n in names:
|
|
92
|
+
if n in directives:
|
|
93
|
+
return directives[n]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
elem = pick("script-src-elem", "script-src", "default-src")
|
|
97
|
+
attr = pick("script-src-attr", "script-src", "default-src")
|
|
98
|
+
ev = pick("script-src", "default-src")
|
|
99
|
+
if elem is None and attr is None and ev is None:
|
|
100
|
+
# No script policy to judge (e.g. frame-ancestors only); _weak_csp decides.
|
|
101
|
+
return False, None
|
|
102
|
+
# Explicit weaknesses first: they count even when another chain is unresolved.
|
|
103
|
+
if ev is not None and "'unsafe-eval'" in ev:
|
|
104
|
+
return True, "script sources allow 'unsafe-eval'"
|
|
105
|
+
# CSP3: with 'strict-dynamic', browsers ignore host/scheme sources and
|
|
106
|
+
# 'unsafe-inline' (kept only as a fallback for old browsers). This is the
|
|
107
|
+
# recommended strict policy, so it is exempt, per list.
|
|
108
|
+
def inline_ok(sources: list[str]) -> bool:
|
|
109
|
+
# A nonce or hash makes browsers ignore 'unsafe-inline'.
|
|
110
|
+
# (a valid, non-empty base64 value is required; browsers drop invalid ones).
|
|
111
|
+
hardened = any(_INTEGRITY_RE.fullmatch(t) for t in sources)
|
|
112
|
+
return "'unsafe-inline'" not in sources or hardened
|
|
113
|
+
|
|
114
|
+
if elem is not None and "'strict-dynamic'" not in elem:
|
|
115
|
+
if any(t in _ANY_SCHEME or _ANY_HOST_RE.match(t) for t in elem):
|
|
116
|
+
return True, "script sources allow any origin (wildcard or bare scheme)"
|
|
117
|
+
if not inline_ok(elem):
|
|
118
|
+
return True, "script sources allow 'unsafe-inline'"
|
|
119
|
+
# Origins are meaningless for inline handlers; only 'unsafe-inline' matters.
|
|
120
|
+
if attr is not None and "'strict-dynamic'" not in attr and not inline_ok(attr):
|
|
121
|
+
return True, "script sources allow 'unsafe-inline' (inline event handlers)"
|
|
122
|
+
# A chain that resolves to nothing means that use has no restriction at all.
|
|
123
|
+
if elem is None:
|
|
124
|
+
return True, _UNRESOLVED + ": <script> elements are unrestricted"
|
|
125
|
+
if attr is None:
|
|
126
|
+
return True, _UNRESOLVED + ": inline event handlers are unrestricted"
|
|
127
|
+
if ev is None:
|
|
128
|
+
return True, _UNRESOLVED + ": eval() is unrestricted"
|
|
129
|
+
return True, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _frame_ancestors(policy: str) -> list[str] | None:
|
|
133
|
+
"""Sources of the first frame-ancestors directive in one policy, if any."""
|
|
134
|
+
for part in policy.split(";"):
|
|
135
|
+
toks = part.strip().lower().split()
|
|
136
|
+
if toks and toks[0] == "frame-ancestors":
|
|
137
|
+
return toks[1:]
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _weak_csp(value: str) -> str | None:
|
|
142
|
+
"""Flag a CSP whose script controls let any / inline script run, or whose
|
|
143
|
+
frame-ancestors lets any site frame the page.
|
|
144
|
+
|
|
145
|
+
Several policies (repeated headers, folded with ',') are enforced together,
|
|
146
|
+
so the result is as strong as the strongest of them, in any order.
|
|
147
|
+
"""
|
|
148
|
+
policies = value.split(",")
|
|
149
|
+
# Browsers enforce every policy: framing is allowed only if all of them allow
|
|
150
|
+
# it, so one restrictive frame-ancestors is enough (order does not matter).
|
|
151
|
+
fa = [f for f in (_frame_ancestors(p) for p in policies) if f is not None]
|
|
152
|
+
fa_restricts = any(
|
|
153
|
+
not any(t in _ANY_SCHEME or _ANY_HOST_RE.match(t) for t in srcs) for srcs in fa
|
|
154
|
+
)
|
|
155
|
+
if fa and not fa_restricts:
|
|
156
|
+
return "frame-ancestors allows any site to frame the page"
|
|
157
|
+
|
|
158
|
+
reasons: list[str] = []
|
|
159
|
+
for policy in policies:
|
|
160
|
+
judged, reason = _csp_policy(policy)
|
|
161
|
+
if judged and reason is None:
|
|
162
|
+
return None
|
|
163
|
+
if reason:
|
|
164
|
+
reasons.append(reason)
|
|
165
|
+
definite = [r for r in reasons if not r.startswith(_UNRESOLVED)]
|
|
166
|
+
if definite:
|
|
167
|
+
return definite[0]
|
|
168
|
+
if fa_restricts:
|
|
169
|
+
# A restrictive frame-ancestors is the deliberately conservative baseline
|
|
170
|
+
# Umbermark's own fix ships. It stays accepted when the policy only adds
|
|
171
|
+
# partial script directives: those leave nothing weaker than the baseline.
|
|
172
|
+
return None
|
|
173
|
+
if reasons:
|
|
174
|
+
return reasons[0]
|
|
175
|
+
return "no script-src, default-src or frame-ancestors: the policy restricts nothing"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _tokens(value: str) -> set[str]:
|
|
179
|
+
"""Distinct lowercased comma-separated tokens (proxy + app may both set it)."""
|
|
180
|
+
return {t.strip().lower() for t in value.split(",") if t.strip()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _weak_xfo(value: str) -> str | None:
|
|
184
|
+
toks = _tokens(value)
|
|
185
|
+
# Browsers accept a repeated identical value; conflicting/bad ones are weak.
|
|
186
|
+
if len(toks) != 1 or not toks <= _XFO_OK:
|
|
187
|
+
return f"value {value.strip()!r} is not DENY or SAMEORIGIN"
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _weak_xcto(value: str) -> str | None:
|
|
192
|
+
if _tokens(value) != {"nosniff"}:
|
|
193
|
+
return f"value {value.strip()!r} is not nosniff"
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_VALUE_CHECKS = {
|
|
198
|
+
"strict-transport-security": _weak_hsts,
|
|
199
|
+
"content-security-policy": _weak_csp,
|
|
200
|
+
"x-frame-options": _weak_xfo,
|
|
201
|
+
"x-content-type-options": _weak_xcto,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class HttpSecurityHeadersCheck(ExternalCheck):
|
|
206
|
+
id = "external_http_security_headers"
|
|
207
|
+
title = "HTTP responses set the core security headers"
|
|
208
|
+
category = "external_web"
|
|
209
|
+
severity = Severity.MEDIUM
|
|
210
|
+
weight = 6
|
|
211
|
+
remediation = (
|
|
212
|
+
"Set the standard hardening headers on HTTPS responses: "
|
|
213
|
+
"Strict-Transport-Security (HSTS, max-age of at least 6 months), Content-Security-Policy, "
|
|
214
|
+
"X-Frame-Options (or CSP frame-ancestors), and X-Content-Type-Options: "
|
|
215
|
+
"nosniff. Add them at the web server or reverse proxy."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def run(self, probe: ExternalProbe) -> Finding:
|
|
219
|
+
headers = probe.http_headers(443, tls=True)
|
|
220
|
+
if headers is None:
|
|
221
|
+
return self._finding(
|
|
222
|
+
Verdict.SKIP,
|
|
223
|
+
skip_reason="no HTTPS service answered on port 443",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
lowered = {k.lower(): str(v) for k, v in headers.items()}
|
|
227
|
+
missing = [name for name in _EXPECTED if name not in lowered]
|
|
228
|
+
weak = [
|
|
229
|
+
f"{name} ({reason})"
|
|
230
|
+
for name, judge in _VALUE_CHECKS.items()
|
|
231
|
+
if name in lowered and (reason := judge(lowered[name]))
|
|
232
|
+
]
|
|
233
|
+
if missing or weak:
|
|
234
|
+
parts = []
|
|
235
|
+
if missing:
|
|
236
|
+
listing = ", ".join(f"{n} ({_EXPECTED[n]})" for n in missing)
|
|
237
|
+
parts.append(f"missing security headers: {listing}")
|
|
238
|
+
if weak:
|
|
239
|
+
parts.append("weak header values: " + "; ".join(weak))
|
|
240
|
+
hsts = lowered.get("strict-transport-security")
|
|
241
|
+
note = _hsts_note(hsts) if hsts is not None else None
|
|
242
|
+
if note:
|
|
243
|
+
parts.append(note)
|
|
244
|
+
return self._finding(Verdict.WARN, evidence=" | ".join(parts))
|
|
245
|
+
evidence = "all core security headers present with sound values"
|
|
246
|
+
note = _hsts_note(lowered["strict-transport-security"])
|
|
247
|
+
if note:
|
|
248
|
+
evidence += f" ({note})"
|
|
249
|
+
return self._finding(Verdict.PASS, evidence=evidence)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""External check: legacy plaintext services exposed to the internet.
|
|
2
|
+
|
|
3
|
+
Telnet, FTP, and the BSD r-services transmit credentials and data in the clear
|
|
4
|
+
and have no place facing the public internet. We only test whether the port
|
|
5
|
+
answers; we never authenticate.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
10
|
+
from umbermark.core.external.check import ExternalCheck
|
|
11
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
12
|
+
|
|
13
|
+
# Plaintext / legacy services that should never be internet-facing.
|
|
14
|
+
_LEGACY = {
|
|
15
|
+
23: "Telnet",
|
|
16
|
+
21: "FTP",
|
|
17
|
+
69: "TFTP",
|
|
18
|
+
512: "rexec",
|
|
19
|
+
513: "rlogin",
|
|
20
|
+
514: "rsh",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LegacyPlaintextServicesCheck(ExternalCheck):
|
|
25
|
+
active = True # port probing: ownership-verified targets only (UMBR-26)
|
|
26
|
+
id = "external_legacy_plaintext_services"
|
|
27
|
+
title = "No legacy plaintext services exposed (telnet, FTP, r-services)"
|
|
28
|
+
category = "external_network"
|
|
29
|
+
severity = Severity.HIGH
|
|
30
|
+
weight = 12
|
|
31
|
+
remediation = (
|
|
32
|
+
"Disable telnet, FTP, and the BSD r-services on the public interface. "
|
|
33
|
+
"Use SSH/SFTP instead, and block ports 21/23/69/512-514 at the firewall. "
|
|
34
|
+
"These protocols send credentials and data in cleartext."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def run(self, probe: ExternalProbe) -> Finding:
|
|
38
|
+
exposed: list[tuple[int, str]] = []
|
|
39
|
+
for port, name in _LEGACY.items():
|
|
40
|
+
try:
|
|
41
|
+
if probe.tcp_connect(port):
|
|
42
|
+
exposed.append((port, name))
|
|
43
|
+
except Exception:
|
|
44
|
+
continue
|
|
45
|
+
if exposed:
|
|
46
|
+
listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
|
|
47
|
+
return self._finding(
|
|
48
|
+
Verdict.FAIL, evidence=f"plaintext service reachable: {listing}"
|
|
49
|
+
)
|
|
50
|
+
return self._finding(
|
|
51
|
+
Verdict.PASS, evidence="no legacy plaintext services reachable"
|
|
52
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""External check: remote-management interfaces exposed to the internet.
|
|
2
|
+
|
|
3
|
+
The attack-surface inventory. Remote desktop, container/orchestrator control
|
|
4
|
+
planes, and cluster databases should sit behind a VPN or firewall — not answer
|
|
5
|
+
the public internet. Unlike the data-store (critical) and plaintext-legacy (high)
|
|
6
|
+
checks, an open management port is expanded surface rather than a guaranteed
|
|
7
|
+
breach, so this is advisory (WARN). We only test whether the port answers; we
|
|
8
|
+
never authenticate.
|
|
9
|
+
|
|
10
|
+
SSH (22) and the conventional web ports (80/443) are the expected baseline and
|
|
11
|
+
are deliberately NOT flagged — open SSH is normal, and SSH crypto hygiene is
|
|
12
|
+
covered separately.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
17
|
+
from umbermark.core.external.check import ExternalCheck
|
|
18
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
19
|
+
|
|
20
|
+
# Remote-management / control-plane ports that should not face the public
|
|
21
|
+
# internet. Kept distinct from data stores (port-disjoint) and plaintext legacy.
|
|
22
|
+
_MGMT = {
|
|
23
|
+
3389: "RDP",
|
|
24
|
+
5900: "VNC",
|
|
25
|
+
5901: "VNC",
|
|
26
|
+
2375: "Docker API (plaintext)",
|
|
27
|
+
2376: "Docker API (TLS)",
|
|
28
|
+
6443: "Kubernetes API",
|
|
29
|
+
10250: "Kubelet API",
|
|
30
|
+
2379: "etcd",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OpenManagementPortsCheck(ExternalCheck):
|
|
35
|
+
active = True # port probing: ownership-verified targets only (UMBR-26)
|
|
36
|
+
id = "external_open_management_ports"
|
|
37
|
+
title = "Remote-management interfaces not exposed to the internet"
|
|
38
|
+
category = "external_network"
|
|
39
|
+
severity = Severity.MEDIUM
|
|
40
|
+
weight = 6
|
|
41
|
+
remediation = (
|
|
42
|
+
"Restrict remote-management interfaces (RDP, VNC, the Docker/Kubernetes "
|
|
43
|
+
"control plane, etcd) to a VPN or a trusted IP allow-list at the firewall. "
|
|
44
|
+
"These should never be reachable from the open internet — expose only the "
|
|
45
|
+
"ports your service actually needs (typically 80/443, plus SSH)."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def run(self, probe: ExternalProbe) -> Finding:
|
|
49
|
+
exposed: list[tuple[int, str]] = []
|
|
50
|
+
for port, name in _MGMT.items():
|
|
51
|
+
try:
|
|
52
|
+
if probe.tcp_connect(port):
|
|
53
|
+
exposed.append((port, name))
|
|
54
|
+
except Exception:
|
|
55
|
+
continue # one port's probe error shouldn't sink the check
|
|
56
|
+
if exposed:
|
|
57
|
+
listing = ", ".join(f"{name} ({port})" for port, name in sorted(exposed))
|
|
58
|
+
return self._finding(
|
|
59
|
+
Verdict.WARN,
|
|
60
|
+
evidence=f"management interface reachable from the internet: {listing}",
|
|
61
|
+
)
|
|
62
|
+
return self._finding(
|
|
63
|
+
Verdict.PASS,
|
|
64
|
+
evidence="no remote-management ports reachable from the internet",
|
|
65
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""External check: weak SSH algorithms on alternate SSH ports (UMBR-38).
|
|
2
|
+
|
|
3
|
+
``external_ssh_weak_algorithms`` only reads port 22. An external scanner has no
|
|
4
|
+
shell on the host, so it cannot read sshd's ``Port`` directives or an
|
|
5
|
+
``ssh.socket`` override. Instead this ACTIVE check probes a short list of the
|
|
6
|
+
ports administrators commonly move SSH to and runs the same weak-algorithm
|
|
7
|
+
evaluation on every one that answers with an SSH banner. The finding names the
|
|
8
|
+
port. Port probing is gated behind verified ownership (UMBR-26): for an
|
|
9
|
+
unverified target the engine skips this check with an explanatory reason.
|
|
10
|
+
|
|
11
|
+
The on-box ``ssh_weak_ciphers`` check (``sshd -T``) already covers every
|
|
12
|
+
configured port; this is the outside-in complement for hosts scanned without
|
|
13
|
+
credentials.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from umbermark.core.check import Finding, Severity, Verdict
|
|
18
|
+
from umbermark.core.external.check import ExternalCheck
|
|
19
|
+
from umbermark.core.external.checks.ssh_weak_algorithms import find_weak_algorithms
|
|
20
|
+
from umbermark.core.external.engine import ALT_SSH_UNVERIFIED_SKIP_REASON
|
|
21
|
+
from umbermark.core.external.probe import ExternalProbe
|
|
22
|
+
|
|
23
|
+
# Common non-default SSH ports (22 itself is covered by the passive check).
|
|
24
|
+
_ALT_SSH_PORTS = (2222, 22222, 2022, 822, 2200, 22022, 2322)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SshAltPortsCheck(ExternalCheck):
|
|
28
|
+
active = True # port probing: ownership-verified targets only (UMBR-26)
|
|
29
|
+
unverified_skip_reason = ALT_SSH_UNVERIFIED_SKIP_REASON
|
|
30
|
+
id = "external_ssh_alt_ports"
|
|
31
|
+
title = "SSH on alternate ports offers no weak or deprecated algorithms"
|
|
32
|
+
category = "external_network"
|
|
33
|
+
severity = Severity.HIGH
|
|
34
|
+
weight = 12
|
|
35
|
+
remediation = (
|
|
36
|
+
"Apply the same algorithm restrictions to every SSH port. KexAlgorithms, Ciphers, MACs "
|
|
37
|
+
"and HostKeyAlgorithms in sshd_config apply to all ports sshd listens on: pin them to a "
|
|
38
|
+
"modern list (e.g. curve25519-sha256, chacha20-poly1305@openssh.com / aes256-gcm, "
|
|
39
|
+
"hmac-sha2-512-etm, ssh-ed25519 / rsa-sha2-512). If an extra SSH port is not needed, "
|
|
40
|
+
"remove its Port line (and any ssh.socket override) and close it at the firewall."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def run(self, probe: ExternalProbe) -> Finding:
|
|
44
|
+
found: list[int] = [] # ports that answered with an SSH banner
|
|
45
|
+
weak_by_port: dict[int, list[tuple[str, str]]] = {}
|
|
46
|
+
for port in _ALT_SSH_PORTS:
|
|
47
|
+
try:
|
|
48
|
+
algos = probe.ssh_algorithms(port)
|
|
49
|
+
except Exception:
|
|
50
|
+
continue # one port's probe error shouldn't sink the check
|
|
51
|
+
if algos is None:
|
|
52
|
+
continue
|
|
53
|
+
found.append(port)
|
|
54
|
+
weak = find_weak_algorithms(algos)
|
|
55
|
+
if weak:
|
|
56
|
+
weak_by_port[port] = weak
|
|
57
|
+
if weak_by_port:
|
|
58
|
+
parts = [
|
|
59
|
+
f"port {port}: " + ", ".join(f"{n} ({cat})" for cat, n in weak)
|
|
60
|
+
for port, weak in sorted(weak_by_port.items())
|
|
61
|
+
]
|
|
62
|
+
return self._finding(
|
|
63
|
+
Verdict.FAIL,
|
|
64
|
+
evidence="weak/deprecated SSH algorithms offered on alternate ports: "
|
|
65
|
+
+ "; ".join(parts),
|
|
66
|
+
)
|
|
67
|
+
if found:
|
|
68
|
+
ports = ", ".join(str(p) for p in sorted(found))
|
|
69
|
+
return self._finding(
|
|
70
|
+
Verdict.PASS,
|
|
71
|
+
evidence=f"SSH on alternate port(s) {ports} offers only modern algorithms",
|
|
72
|
+
)
|
|
73
|
+
tried = ", ".join(str(p) for p in _ALT_SSH_PORTS)
|
|
74
|
+
return self._finding(
|
|
75
|
+
Verdict.PASS,
|
|
76
|
+
evidence=f"no SSH service answered on the alternate ports checked ({tried})",
|
|
77
|
+
)
|