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,713 @@
|
|
|
1
|
+
"""Host-firewall model: parse nft / iptables / ufw, answer "is port P reachable?".
|
|
2
|
+
|
|
3
|
+
One parser, one evaluator, shared by every check that needs to judge exposure
|
|
4
|
+
(listen_vs_firewall_delta, public_listen_anomalies, and the AccessContext
|
|
5
|
+
reachability detector).
|
|
6
|
+
|
|
7
|
+
The question answered is: *would a new inbound tcp/udp connection to port P,
|
|
8
|
+
arriving on a public interface from an arbitrary internet address, be accepted
|
|
9
|
+
by the host firewall?* "Public interface" means anything that is not loopback
|
|
10
|
+
and not a VPN-overlay device (tailscale0, wg0, ...), see `is_overlay_iface`.
|
|
11
|
+
|
|
12
|
+
Supported syntax (enough for real-world rulesets, not a full nft/iptables VM):
|
|
13
|
+
|
|
14
|
+
* nft: tables/chains, base-chain `hook input` + `policy`, `jump`/`goto`,
|
|
15
|
+
`iif`/`iifname` (single, `!=`, sets, `*` wildcards), `tcp|udp|th dport`
|
|
16
|
+
(single, ranges, `{ 80, 443 }` sets, named `@sets`), `meta l4proto`,
|
|
17
|
+
`ip protocol`, `ct state`, `saddr` restrictions. Unknown match expressions
|
|
18
|
+
never grant exposure by themselves: an `accept` guarded by something we do
|
|
19
|
+
not understand is reported as RESTRICTED, never as ACCEPTED.
|
|
20
|
+
* iptables -S: `-P INPUT`, `-N`, `-A`, `-p`, `-i`, `-s`, `--dport`,
|
|
21
|
+
`-m multiport --dports`, conntrack/state, jumps to user chains.
|
|
22
|
+
* ufw status [verbose]: default incoming policy + ALLOW/DENY/REJECT/LIMIT rows.
|
|
23
|
+
|
|
24
|
+
Limits (documented, not hidden): ip6tables is not collected; nft `ip daddr`
|
|
25
|
+
and `fib` expressions are treated as unknown conditions; evaluation is for NEW
|
|
26
|
+
connections only (established/related rules never match).
|
|
27
|
+
|
|
28
|
+
When nothing could be read, `Firewall.evaluate` returns UNKNOWN. UNKNOWN is
|
|
29
|
+
never promoted to "safe" by callers.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import fnmatch
|
|
34
|
+
import ipaddress
|
|
35
|
+
import re
|
|
36
|
+
import shlex
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from enum import Enum
|
|
39
|
+
|
|
40
|
+
# Interface-name prefixes of VPN / overlay devices. Traffic arriving on these
|
|
41
|
+
# is authenticated by the overlay, so it is not "public" exposure.
|
|
42
|
+
_OVERLAY_PREFIXES = ("tailscale", "wg", "zt", "nebula", "tun", "tap", "wt", "utun")
|
|
43
|
+
PUBLIC_SENTINEL = "<public>"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_overlay_iface(name: str) -> bool:
|
|
47
|
+
return name.lower().startswith(_OVERLAY_PREFIXES)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_loopback_iface(name: str) -> bool:
|
|
51
|
+
return name == "lo" or name.startswith("lo:")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
# Address classification (shared with core.access)
|
|
56
|
+
# --------------------------------------------------------------------------- #
|
|
57
|
+
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
|
58
|
+
_ULA = ipaddress.ip_network("fc00::/7")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def classify_address(addr: str) -> str:
|
|
62
|
+
"""Return 'loopback' | 'linklocal' | 'private' | 'cgnat' | 'public' | 'unknown'.
|
|
63
|
+
|
|
64
|
+
Deliberately simple: RFC1918 / ULA are 'private', 100.64/10 is 'cgnat'
|
|
65
|
+
(tailnet-style overlays live here), everything else routable is 'public'.
|
|
66
|
+
RFC 5737 documentation ranges count as public so scrubbed fixtures work.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
ip = ipaddress.ip_address(addr.split("%")[0])
|
|
70
|
+
except ValueError:
|
|
71
|
+
return "unknown"
|
|
72
|
+
if ip.is_loopback:
|
|
73
|
+
return "loopback"
|
|
74
|
+
if ip.is_link_local:
|
|
75
|
+
return "linklocal"
|
|
76
|
+
if ip.is_unspecified:
|
|
77
|
+
return "unknown"
|
|
78
|
+
if ip.version == 4:
|
|
79
|
+
if ip in _CGNAT:
|
|
80
|
+
return "cgnat"
|
|
81
|
+
if any(ip in ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")):
|
|
82
|
+
return "private"
|
|
83
|
+
else:
|
|
84
|
+
if ip in _ULA:
|
|
85
|
+
return "private"
|
|
86
|
+
return "public"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sources_are_private(sources: list[str]) -> bool:
|
|
90
|
+
"""True iff every source CIDR/address is private, CGNAT, loopback or link-local."""
|
|
91
|
+
if not sources:
|
|
92
|
+
return False
|
|
93
|
+
for s in sources:
|
|
94
|
+
base = s.split("/")[0].strip()
|
|
95
|
+
if classify_address(base) not in ("private", "cgnat", "loopback", "linklocal"):
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
# Model
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
class Exposure(Enum):
|
|
104
|
+
ACCEPTED = "accepted" # new connection from a public iface is accepted
|
|
105
|
+
BLOCKED = "blocked" # dropped (policy or rule) from every public iface
|
|
106
|
+
RESTRICTED = "restricted" # accepted only for specific source addresses / unknown guards
|
|
107
|
+
UNKNOWN = "unknown" # no readable firewall
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class PortVerdict:
|
|
112
|
+
state: Exposure
|
|
113
|
+
explicit: bool = False # accepted by an explicit rule (not just policy accept)
|
|
114
|
+
sources: list[str] = field(default_factory=list) # for RESTRICTED
|
|
115
|
+
detail: str = ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Rule:
|
|
120
|
+
ifaces: list[str] | None = None # None = any
|
|
121
|
+
iface_negate: bool = False
|
|
122
|
+
protos: set[str] | None = None # None = any L4 proto
|
|
123
|
+
dports: list[tuple[int, int]] | None = None
|
|
124
|
+
dport_negate: bool = False
|
|
125
|
+
new_ok: bool = True # False when ct/state excludes NEW
|
|
126
|
+
sources: list[str] = field(default_factory=list)
|
|
127
|
+
unknown: bool = False # a condition we could not interpret
|
|
128
|
+
action: str | None = None # accept|drop|return|jump|None (no-op)
|
|
129
|
+
target: str | None = None # jump target chain
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Chain:
|
|
134
|
+
name: str
|
|
135
|
+
table: str = ""
|
|
136
|
+
hook: str | None = None # 'input' for base input chains
|
|
137
|
+
policy: str = "accept"
|
|
138
|
+
rules: list[Rule] = field(default_factory=list)
|
|
139
|
+
source: str = "" # nft | iptables | ufw
|
|
140
|
+
family: str = "inet" # inet (v4+v6) | ip (v4) | ip6 (v6)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ports_match(rule: Rule, port: int) -> bool | None:
|
|
144
|
+
if rule.dports is None:
|
|
145
|
+
return True
|
|
146
|
+
inside = any(lo <= port <= hi for lo, hi in rule.dports)
|
|
147
|
+
return (not inside) if rule.dport_negate else inside
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _iface_match(rule: Rule, iface: str) -> bool:
|
|
151
|
+
if rule.ifaces is None:
|
|
152
|
+
return True
|
|
153
|
+
hit = any(fnmatch.fnmatchcase(iface, pat) for pat in rule.ifaces)
|
|
154
|
+
return (not hit) if rule.iface_negate else hit
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _classify_match(rule: Rule, port: int, proto: str, iface: str) -> str:
|
|
158
|
+
"""'no' | 'yes' | 'partial' (matches but guarded by source/unknown condition)."""
|
|
159
|
+
if not rule.new_ok:
|
|
160
|
+
return "no"
|
|
161
|
+
if rule.protos is not None and proto not in rule.protos:
|
|
162
|
+
return "no"
|
|
163
|
+
if not _iface_match(rule, iface):
|
|
164
|
+
return "no"
|
|
165
|
+
if rule.dports is not None and not _ports_match(rule, port):
|
|
166
|
+
return "no"
|
|
167
|
+
if rule.unknown or rule.sources:
|
|
168
|
+
return "partial"
|
|
169
|
+
return "yes"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class _Decision:
|
|
174
|
+
kind: str # accept | drop | return
|
|
175
|
+
explicit: bool = False
|
|
176
|
+
restricted: list[str] = field(default_factory=list)
|
|
177
|
+
restricted_unknown: bool = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _eval_chain(chains: dict[tuple[str, str], Chain], chain: Chain, port: int, proto: str,
|
|
181
|
+
iface: str, depth: int = 0) -> _Decision:
|
|
182
|
+
restricted: list[str] = []
|
|
183
|
+
r_unknown = False
|
|
184
|
+
if depth > 16: # jump loops in hostile/odd rulesets
|
|
185
|
+
return _Decision("return")
|
|
186
|
+
for rule in chain.rules:
|
|
187
|
+
m = _classify_match(rule, port, proto, iface)
|
|
188
|
+
if m == "no" or rule.action is None:
|
|
189
|
+
continue
|
|
190
|
+
if rule.action in ("jump", "goto"):
|
|
191
|
+
tgt = chains.get((chain.table, rule.target or ""))
|
|
192
|
+
if tgt is None:
|
|
193
|
+
continue
|
|
194
|
+
sub = _eval_chain(chains, tgt, port, proto, iface, depth + 1)
|
|
195
|
+
restricted += sub.restricted
|
|
196
|
+
r_unknown = r_unknown or sub.restricted_unknown
|
|
197
|
+
if sub.kind in ("return", "policy"):
|
|
198
|
+
# `jump` resumes the calling chain. `goto` (or a nested goto that
|
|
199
|
+
# fell through) does NOT: the base-chain policy applies instead.
|
|
200
|
+
if m == "yes" and (rule.action == "goto" or sub.kind == "policy"):
|
|
201
|
+
return _Decision("policy", False, restricted, r_unknown)
|
|
202
|
+
continue
|
|
203
|
+
if m == "partial":
|
|
204
|
+
if sub.kind == "accept":
|
|
205
|
+
restricted += rule.sources
|
|
206
|
+
r_unknown = r_unknown or rule.unknown
|
|
207
|
+
continue
|
|
208
|
+
return _Decision(sub.kind, sub.explicit, restricted, r_unknown)
|
|
209
|
+
if m == "partial":
|
|
210
|
+
if rule.action == "accept":
|
|
211
|
+
restricted += rule.sources
|
|
212
|
+
r_unknown = r_unknown or rule.unknown
|
|
213
|
+
continue
|
|
214
|
+
if rule.action == "return":
|
|
215
|
+
return _Decision("return", False, restricted, r_unknown)
|
|
216
|
+
return _Decision(rule.action, True, restricted, r_unknown)
|
|
217
|
+
return _Decision("return", False, restricted, r_unknown)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------- #
|
|
221
|
+
# nft parser
|
|
222
|
+
# --------------------------------------------------------------------------- #
|
|
223
|
+
def _parse_port_spec(spec: str, sets: dict[str, list[tuple[int, int]]]) -> list[tuple[int, int]] | None:
|
|
224
|
+
spec = spec.strip()
|
|
225
|
+
if spec.startswith("@"):
|
|
226
|
+
return sets.get(spec[1:])
|
|
227
|
+
spec = spec.strip("{} ")
|
|
228
|
+
out: list[tuple[int, int]] = []
|
|
229
|
+
for part in re.split(r"[,\s]+", spec):
|
|
230
|
+
if not part:
|
|
231
|
+
continue
|
|
232
|
+
m = re.fullmatch(r"(\d+)(?:[-:](\d+))?", part)
|
|
233
|
+
if not m:
|
|
234
|
+
return None
|
|
235
|
+
lo = int(m.group(1))
|
|
236
|
+
out.append((lo, int(m.group(2) or lo)))
|
|
237
|
+
return out or None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _parse_str_set(spec: str) -> list[str]:
|
|
241
|
+
return [s for s in re.findall(r'"([^"]*)"|([\w*.-]+)', spec.strip("{} "))
|
|
242
|
+
for s in [s[0] or s[1]] if s]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_LOG_RE = re.compile(r'\blog\b(?:\s+prefix\s+"[^"]*")?(?:\s+level\s+\w+)?(?:\s+group\s+\d+)?(?:\s+flags\s+\w+)?')
|
|
246
|
+
_COUNTER_RE = re.compile(r"\bcounter\b(?:\s+packets\s+\d+\s+bytes\s+\d+)?")
|
|
247
|
+
_IIF_RE = re.compile(r'(?:meta\s+)?\bi(?:ifname|if)\s+(!=\s*)?(\{[^}]*\}|"[^"]*"|\S+)')
|
|
248
|
+
_DPORT_RE = re.compile(r"(?:\b(tcp|udp|th|sctp|dccp)\s+)?\bdport\s+(!=\s*)?(\{[^}]*\}|@\w+|[\d:-]+)")
|
|
249
|
+
_L4_RE = re.compile(r"(?:meta\s+l4proto|ip\s+protocol|ip6\s+nexthdr)\s+(\{[^}]*\}|\S+)")
|
|
250
|
+
_CT_RE = re.compile(r"\bct\s+state\s+(\{[^}]*\}|[\w,]+)")
|
|
251
|
+
_SADDR_RE = re.compile(r"\b(?:ip6?)\s+saddr\s+(!=\s*)?(\{[^}]*\}|\S+)")
|
|
252
|
+
_ACTION_RE = re.compile(r"\b(accept|drop|reject|return|continue)\b(?:\s+with\s+\S+(?:\s+type\s+\S+)?)?")
|
|
253
|
+
_JUMP_RE = re.compile(r"\b(jump|goto)\s+(\S+)")
|
|
254
|
+
# nft rule comments are free text: strip them BEFORE any keyword matching.
|
|
255
|
+
_NFT_COMMENT_RE = re.compile(r'\bcomment\s+"(?:[^"\\]|\\.)*"')
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_nft_rule(line: str, sets: dict[str, list[tuple[int, int]]]) -> Rule:
|
|
259
|
+
rule = Rule()
|
|
260
|
+
s = _COUNTER_RE.sub(" ", line)
|
|
261
|
+
s = _LOG_RE.sub(" ", s)
|
|
262
|
+
|
|
263
|
+
m = _JUMP_RE.search(s)
|
|
264
|
+
if m:
|
|
265
|
+
rule.action, rule.target = m.group(1), m.group(2).rstrip(";")
|
|
266
|
+
s = s[:m.start()] + " " + s[m.end():]
|
|
267
|
+
else:
|
|
268
|
+
m = _ACTION_RE.search(s)
|
|
269
|
+
if m:
|
|
270
|
+
kw = m.group(1)
|
|
271
|
+
rule.action = {"accept": "accept", "drop": "drop", "reject": "drop",
|
|
272
|
+
"return": "return", "continue": None}[kw]
|
|
273
|
+
s = s[:m.start()] + " " + s[m.end():]
|
|
274
|
+
|
|
275
|
+
def take(rx: re.Pattern[str]) -> re.Match[str] | None:
|
|
276
|
+
nonlocal s
|
|
277
|
+
mm = rx.search(s)
|
|
278
|
+
if mm:
|
|
279
|
+
s = s[:mm.start()] + " " + s[mm.end():]
|
|
280
|
+
return mm
|
|
281
|
+
|
|
282
|
+
mm = take(_IIF_RE)
|
|
283
|
+
if mm:
|
|
284
|
+
rule.iface_negate = bool(mm.group(1))
|
|
285
|
+
rule.ifaces = _parse_str_set(mm.group(2)) or None
|
|
286
|
+
if rule.ifaces is None:
|
|
287
|
+
rule.unknown = True
|
|
288
|
+
|
|
289
|
+
mm = take(_DPORT_RE)
|
|
290
|
+
if mm:
|
|
291
|
+
proto = mm.group(1)
|
|
292
|
+
if proto in ("tcp", "udp"):
|
|
293
|
+
rule.protos = {proto}
|
|
294
|
+
elif proto in ("sctp", "dccp"):
|
|
295
|
+
rule.protos = {proto}
|
|
296
|
+
ports = _parse_port_spec(mm.group(3), sets)
|
|
297
|
+
if ports is None:
|
|
298
|
+
rule.unknown = True
|
|
299
|
+
else:
|
|
300
|
+
rule.dports = ports
|
|
301
|
+
rule.dport_negate = bool(mm.group(2))
|
|
302
|
+
|
|
303
|
+
mm = take(_L4_RE)
|
|
304
|
+
if mm:
|
|
305
|
+
names = set(re.findall(r"[a-z0-9-]+", mm.group(1).lower()))
|
|
306
|
+
names = {"icmp" if n == "ipv6-icmp" else n for n in names}
|
|
307
|
+
rule.protos = names if rule.protos is None else (rule.protos & names)
|
|
308
|
+
|
|
309
|
+
mm = take(_CT_RE)
|
|
310
|
+
if mm:
|
|
311
|
+
states = set(re.findall(r"[a-z]+", mm.group(1).lower()))
|
|
312
|
+
rule.new_ok = "new" in states
|
|
313
|
+
|
|
314
|
+
mm = take(_SADDR_RE)
|
|
315
|
+
if mm:
|
|
316
|
+
if mm.group(1): # '!=' form: restricts, but not to a positive list
|
|
317
|
+
rule.unknown = True
|
|
318
|
+
else:
|
|
319
|
+
rule.sources += re.findall(r"[0-9a-fA-F:.]+(?:/\d+)?", mm.group(2))
|
|
320
|
+
|
|
321
|
+
leftover = re.sub(r"[;\s]+", " ", s).strip()
|
|
322
|
+
if leftover and not leftover.startswith(("comment",)):
|
|
323
|
+
# e.g. `meta mark & 0xff == 0x4 accept`, `limit rate 5/second`, `fib ...`
|
|
324
|
+
rule.unknown = True
|
|
325
|
+
return rule
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def parse_nft(text: str) -> dict[tuple[str, str], Chain]:
|
|
329
|
+
"""Parse `nft list ruleset` into {(table_key, chain_name): Chain}.
|
|
330
|
+
|
|
331
|
+
table_key is "<family> <name>", so a `jump` resolves within its own table.
|
|
332
|
+
"""
|
|
333
|
+
chains: dict[tuple[str, str], Chain] = {}
|
|
334
|
+
sets: dict[str, list[tuple[int, int]]] = {}
|
|
335
|
+
table = ""
|
|
336
|
+
family = "inet"
|
|
337
|
+
chain: Chain | None = None
|
|
338
|
+
set_name: str | None = None
|
|
339
|
+
set_buf: list[str] = []
|
|
340
|
+
|
|
341
|
+
def _finish_set(name: str, body: str) -> None:
|
|
342
|
+
m = re.search(r"elements\s*=\s*\{([^}]*)\}", body)
|
|
343
|
+
sets[name] = (_parse_port_spec(m.group(1), {}) or []) if m else []
|
|
344
|
+
|
|
345
|
+
for raw in text.splitlines():
|
|
346
|
+
line = _NFT_COMMENT_RE.sub(" ", raw).split("#", 1)[0].strip()
|
|
347
|
+
if not line:
|
|
348
|
+
continue
|
|
349
|
+
if set_name is not None:
|
|
350
|
+
if line == "}":
|
|
351
|
+
_finish_set(set_name, " ".join(set_buf))
|
|
352
|
+
set_name, set_buf = None, []
|
|
353
|
+
else:
|
|
354
|
+
set_buf.append(line)
|
|
355
|
+
continue
|
|
356
|
+
m = re.match(r"table\s+(\S+)\s+(\S+)\s*\{", line)
|
|
357
|
+
if m:
|
|
358
|
+
table = f"{m.group(1)} {m.group(2)}"
|
|
359
|
+
family = m.group(1)
|
|
360
|
+
continue
|
|
361
|
+
m = re.match(r"set\s+(\S+)\s*\{(.*)$", line)
|
|
362
|
+
if m:
|
|
363
|
+
rest = m.group(2).strip()
|
|
364
|
+
if rest.endswith("}"): # single-line set definition
|
|
365
|
+
_finish_set(m.group(1), rest[:-1])
|
|
366
|
+
else:
|
|
367
|
+
set_name, set_buf = m.group(1), [rest] if rest else []
|
|
368
|
+
continue
|
|
369
|
+
m = re.match(r"chain\s+(\S+)\s*\{", line)
|
|
370
|
+
if m and chain is None:
|
|
371
|
+
chain = Chain(name=m.group(1), table=table, source="nft", family=family)
|
|
372
|
+
chains[(table, chain.name)] = chain
|
|
373
|
+
continue
|
|
374
|
+
if line == "}":
|
|
375
|
+
if chain is not None:
|
|
376
|
+
chain = None
|
|
377
|
+
else:
|
|
378
|
+
table = ""
|
|
379
|
+
continue
|
|
380
|
+
if chain is None:
|
|
381
|
+
continue
|
|
382
|
+
mt = re.match(r"type\s+\S+\s+hook\s+(\S+)\s+priority\s+\S+;?\s*(?:policy\s+(\w+);)?", line)
|
|
383
|
+
if mt:
|
|
384
|
+
chain.hook = mt.group(1)
|
|
385
|
+
chain.policy = (mt.group(2) or "accept").lower()
|
|
386
|
+
continue
|
|
387
|
+
chain.rules.append(_parse_nft_rule(line, sets))
|
|
388
|
+
return chains
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --------------------------------------------------------------------------- #
|
|
392
|
+
# iptables parser
|
|
393
|
+
# --------------------------------------------------------------------------- #
|
|
394
|
+
_BENIGN_MODULES = {"tcp", "udp", "comment", "addrtype"}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_iptables(text: str, family: str = "ip") -> dict[tuple[str, str], Chain]:
|
|
398
|
+
chains: dict[tuple[str, str], Chain] = {}
|
|
399
|
+
|
|
400
|
+
def get(name: str) -> Chain:
|
|
401
|
+
return chains.setdefault(("", name), Chain(name=name, source="iptables", family=family))
|
|
402
|
+
|
|
403
|
+
for line in text.splitlines():
|
|
404
|
+
line = line.strip()
|
|
405
|
+
if line.startswith("-P "):
|
|
406
|
+
parts = line.split()
|
|
407
|
+
if len(parts) >= 3:
|
|
408
|
+
c = get(parts[1])
|
|
409
|
+
c.policy = "drop" if parts[2] in ("DROP", "REJECT") else "accept"
|
|
410
|
+
if parts[1] == "INPUT":
|
|
411
|
+
c.hook = "input"
|
|
412
|
+
elif line.startswith("-N "):
|
|
413
|
+
get(line.split()[1])
|
|
414
|
+
elif line.startswith("-A "):
|
|
415
|
+
try:
|
|
416
|
+
toks = shlex.split(line)
|
|
417
|
+
except ValueError:
|
|
418
|
+
continue
|
|
419
|
+
c = get(toks[1])
|
|
420
|
+
c.rules.append(_parse_iptables_rule(toks[2:], chains))
|
|
421
|
+
return chains
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_iptables_rule(toks: list[str], chains: dict[tuple[str, str], Chain]) -> Rule:
|
|
425
|
+
rule = Rule()
|
|
426
|
+
neg = False
|
|
427
|
+
i = 0
|
|
428
|
+
module: str | None = None
|
|
429
|
+
while i < len(toks):
|
|
430
|
+
t = toks[i]
|
|
431
|
+
nxt = toks[i + 1] if i + 1 < len(toks) else ""
|
|
432
|
+
if t == "!":
|
|
433
|
+
neg = True
|
|
434
|
+
i += 1
|
|
435
|
+
continue
|
|
436
|
+
if t in ("-p", "--protocol"):
|
|
437
|
+
rule.protos = {nxt} if not neg and nxt != "all" else (None if nxt == "all" else rule.protos)
|
|
438
|
+
if neg:
|
|
439
|
+
rule.unknown = True
|
|
440
|
+
i += 2
|
|
441
|
+
elif t in ("-i", "--in-interface"):
|
|
442
|
+
rule.ifaces = [nxt.replace("+", "*")]
|
|
443
|
+
rule.iface_negate = neg
|
|
444
|
+
i += 2
|
|
445
|
+
elif t in ("-o", "--out-interface", "-d", "--destination"):
|
|
446
|
+
rule.unknown = True
|
|
447
|
+
i += 2
|
|
448
|
+
elif t in ("-s", "--source"):
|
|
449
|
+
if neg:
|
|
450
|
+
rule.unknown = True
|
|
451
|
+
else:
|
|
452
|
+
rule.sources.append(nxt)
|
|
453
|
+
i += 2
|
|
454
|
+
elif t == "-m":
|
|
455
|
+
module = nxt
|
|
456
|
+
if nxt not in _BENIGN_MODULES | {"multiport", "conntrack", "state"}:
|
|
457
|
+
rule.unknown = True
|
|
458
|
+
i += 2
|
|
459
|
+
elif t in ("--dport", "--destination-port"):
|
|
460
|
+
rule.dports = _parse_port_spec(nxt, {})
|
|
461
|
+
rule.dport_negate = neg
|
|
462
|
+
if rule.dports is None:
|
|
463
|
+
rule.unknown = True
|
|
464
|
+
i += 2
|
|
465
|
+
elif t in ("--dports", "--destination-ports"):
|
|
466
|
+
rule.dports = _parse_port_spec(nxt, {})
|
|
467
|
+
rule.dport_negate = neg
|
|
468
|
+
if rule.dports is None:
|
|
469
|
+
rule.unknown = True
|
|
470
|
+
i += 2
|
|
471
|
+
elif t in ("--ctstate", "--state"):
|
|
472
|
+
rule.new_ok = "NEW" in nxt.upper().split(",")
|
|
473
|
+
i += 2
|
|
474
|
+
elif t == "--dst-type":
|
|
475
|
+
# `-m addrtype --dst-type LOCAL` is true for traffic addressed to this host.
|
|
476
|
+
if nxt.upper() != "LOCAL" or neg:
|
|
477
|
+
rule.unknown = True
|
|
478
|
+
i += 2
|
|
479
|
+
elif t == "--comment":
|
|
480
|
+
i += 2
|
|
481
|
+
elif t in ("-j", "-g"):
|
|
482
|
+
tgt = nxt
|
|
483
|
+
if tgt in ("ACCEPT",):
|
|
484
|
+
rule.action = "accept"
|
|
485
|
+
elif tgt in ("DROP", "REJECT"):
|
|
486
|
+
rule.action = "drop"
|
|
487
|
+
elif tgt == "RETURN":
|
|
488
|
+
rule.action = "return"
|
|
489
|
+
elif ("", tgt) in chains:
|
|
490
|
+
rule.action, rule.target = ("goto" if t == "-g" else "jump"), tgt
|
|
491
|
+
else:
|
|
492
|
+
# Non-terminating targets (LOG, MARK, ...) or a chain declared
|
|
493
|
+
# later: forward references are legal only for -N-declared
|
|
494
|
+
# chains, which parse_iptables registers first.
|
|
495
|
+
rule.action = None
|
|
496
|
+
break
|
|
497
|
+
else:
|
|
498
|
+
# Unknown option: assume it takes one value and flag the rule.
|
|
499
|
+
rule.unknown = True
|
|
500
|
+
i += 2 if not nxt.startswith("-") else 1
|
|
501
|
+
continue
|
|
502
|
+
neg = False
|
|
503
|
+
# (module var kept for readability of future extensions)
|
|
504
|
+
_ = module
|
|
505
|
+
return rule
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --------------------------------------------------------------------------- #
|
|
509
|
+
# ufw parser
|
|
510
|
+
# --------------------------------------------------------------------------- #
|
|
511
|
+
_UFW_ROW = re.compile(
|
|
512
|
+
r"^(?P<to>.+?)\s{2,}(?P<action>ALLOW|DENY|REJECT|LIMIT)(?:\s+(?P<dir>IN|OUT|FWD))?\s+(?P<from>.+?)\s*$"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def parse_ufw(text: str) -> tuple[bool, dict[tuple[str, str], Chain]]:
|
|
517
|
+
"""Return (active, chains). Chains is empty when ufw is inactive."""
|
|
518
|
+
if not re.search(r"^Status:\s*active", text, re.M | re.I):
|
|
519
|
+
return False, {}
|
|
520
|
+
policy = "drop" # ufw's shipped default
|
|
521
|
+
m = re.search(r"^Default:\s*(\w+)\s*\(incoming\)", text, re.M | re.I)
|
|
522
|
+
if m:
|
|
523
|
+
policy = "accept" if m.group(1).lower() == "allow" else "drop"
|
|
524
|
+
# ufw lists IPv6 rows as "(v6)"; without any, assume IPv6 is not managed.
|
|
525
|
+
fam = "inet" if "(v6)" in text else "ip"
|
|
526
|
+
chain = Chain(name="ufw", hook="input", policy=policy, source="ufw", family=fam)
|
|
527
|
+
in_rows = False
|
|
528
|
+
for line in text.splitlines():
|
|
529
|
+
if line.startswith("--"):
|
|
530
|
+
in_rows = True
|
|
531
|
+
continue
|
|
532
|
+
if not in_rows:
|
|
533
|
+
continue
|
|
534
|
+
mm = _UFW_ROW.match(line.rstrip())
|
|
535
|
+
if not mm or (mm.group("dir") or "IN") != "IN":
|
|
536
|
+
continue
|
|
537
|
+
to = re.sub(r"\s*\(v6\)", "", mm.group("to")).strip()
|
|
538
|
+
frm = re.sub(r"\s*\(v6\)", "", mm.group("from")).strip()
|
|
539
|
+
rule = Rule(action="accept" if mm.group("action") in ("ALLOW", "LIMIT") else "drop")
|
|
540
|
+
iface = None
|
|
541
|
+
mi = re.search(r"\s+on\s+(\S+)$", to)
|
|
542
|
+
if mi:
|
|
543
|
+
iface, to = mi.group(1), to[:mi.start()].strip()
|
|
544
|
+
if iface:
|
|
545
|
+
rule.ifaces = [iface]
|
|
546
|
+
if to.lower() != "anywhere":
|
|
547
|
+
mp = re.fullmatch(r"([\d,:]+)(?:/(tcp|udp))?", to)
|
|
548
|
+
if not mp:
|
|
549
|
+
rule.unknown = True
|
|
550
|
+
else:
|
|
551
|
+
rule.dports = _parse_port_spec(mp.group(1).replace(":", "-"), {})
|
|
552
|
+
if mp.group(2):
|
|
553
|
+
rule.protos = {mp.group(2)}
|
|
554
|
+
if frm.lower() != "anywhere":
|
|
555
|
+
fm = re.match(r"(\S+)(?:\s+on\s+(\S+))?$", frm)
|
|
556
|
+
if fm and fm.group(1).lower() != "anywhere":
|
|
557
|
+
rule.sources.append(fm.group(1))
|
|
558
|
+
elif fm and fm.group(2) and rule.ifaces is None:
|
|
559
|
+
rule.ifaces = [fm.group(2)]
|
|
560
|
+
else:
|
|
561
|
+
rule.unknown = True
|
|
562
|
+
chain.rules.append(rule)
|
|
563
|
+
return True, {("", "ufw"): chain}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# --------------------------------------------------------------------------- #
|
|
567
|
+
# Facade
|
|
568
|
+
# --------------------------------------------------------------------------- #
|
|
569
|
+
class Firewall:
|
|
570
|
+
"""Parsed host firewall, built from the raw outputs of the collector commands.
|
|
571
|
+
|
|
572
|
+
Pass ``None`` for a command that failed/was unavailable and the raw text
|
|
573
|
+
(possibly empty) for one that succeeded. ``public_ifaces`` are candidate
|
|
574
|
+
internet-facing interface names (from `ip -o addr`); rule-referenced names
|
|
575
|
+
are added automatically, plus a sentinel so unscoped rules are evaluated.
|
|
576
|
+
|
|
577
|
+
Address families: a port is only reported BLOCKED when the drop is proven
|
|
578
|
+
for IPv4 AND for IPv6 (an `inet` table, or matching `ip` + `ip6` base
|
|
579
|
+
chains, or iptables + ip6tables). ``ipv6`` says whether the host has a
|
|
580
|
+
global IPv6 address: False = IPv6 need not be proven, True/None = it must.
|
|
581
|
+
A family with no readable rules yields UNKNOWN, never BLOCKED.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
def __init__(self, nft: str | None = None, iptables: str | None = None,
|
|
585
|
+
ufw: str | None = None, public_ifaces: list[str] | None = None,
|
|
586
|
+
ip6tables: str | None = None, ipv6: bool | None = None) -> None:
|
|
587
|
+
self.sets: list[dict[tuple[str, str], Chain]] = []
|
|
588
|
+
self.sources: list[str] = []
|
|
589
|
+
self.nft_read = nft is not None
|
|
590
|
+
self.ipt_read = False
|
|
591
|
+
self.ip6t_read = False
|
|
592
|
+
if nft is not None:
|
|
593
|
+
self.sets.append(parse_nft(nft))
|
|
594
|
+
self.sources.append("nft")
|
|
595
|
+
if iptables is not None and re.search(r"^-P\s+INPUT\s", iptables, re.M):
|
|
596
|
+
self.sets.append(parse_iptables(iptables, "ip"))
|
|
597
|
+
self.sources.append("iptables")
|
|
598
|
+
self.ipt_read = True
|
|
599
|
+
if ip6tables is not None and re.search(r"^-P\s+INPUT\s", ip6tables, re.M):
|
|
600
|
+
self.sets.append(parse_iptables(ip6tables, "ip6"))
|
|
601
|
+
self.sources.append("ip6tables")
|
|
602
|
+
self.ip6t_read = True
|
|
603
|
+
if ufw is not None:
|
|
604
|
+
active, uc = parse_ufw(ufw)
|
|
605
|
+
if active:
|
|
606
|
+
self.sets.append(uc)
|
|
607
|
+
self.sources.append("ufw")
|
|
608
|
+
self._ifaces = list(public_ifaces or [])
|
|
609
|
+
self.ipv6 = ipv6
|
|
610
|
+
|
|
611
|
+
@property
|
|
612
|
+
def readable(self) -> bool:
|
|
613
|
+
return bool(self.sources)
|
|
614
|
+
|
|
615
|
+
def base_chains(self) -> list[tuple[dict[tuple[str, str], Chain], Chain]]:
|
|
616
|
+
out = []
|
|
617
|
+
for chains in self.sets:
|
|
618
|
+
out += [(chains, c) for c in chains.values() if c.hook == "input"]
|
|
619
|
+
return out
|
|
620
|
+
|
|
621
|
+
@property
|
|
622
|
+
def has_filtering(self) -> bool:
|
|
623
|
+
"""True iff some input hook chain drops by policy or contains any rule."""
|
|
624
|
+
return any(c.policy == "drop" or c.rules for _, c in self.base_chains())
|
|
625
|
+
|
|
626
|
+
def known_ifaces(self) -> set[str]:
|
|
627
|
+
names = set(self._ifaces)
|
|
628
|
+
for chains in self.sets:
|
|
629
|
+
for c in chains.values():
|
|
630
|
+
for r in c.rules:
|
|
631
|
+
for n in r.ifaces or []:
|
|
632
|
+
if "*" not in n:
|
|
633
|
+
names.add(n)
|
|
634
|
+
return names
|
|
635
|
+
|
|
636
|
+
def overlay_ifaces(self) -> list[str]:
|
|
637
|
+
return sorted(n for n in self.known_ifaces() if is_overlay_iface(n))
|
|
638
|
+
|
|
639
|
+
def public_candidates(self) -> list[str]:
|
|
640
|
+
names = {n for n in self.known_ifaces()
|
|
641
|
+
if not is_loopback_iface(n) and not is_overlay_iface(n)}
|
|
642
|
+
return sorted(names) + [PUBLIC_SENTINEL]
|
|
643
|
+
|
|
644
|
+
def _families(self) -> list[str]:
|
|
645
|
+
return ["ip"] if self.ipv6 is False else ["ip", "ip6"]
|
|
646
|
+
|
|
647
|
+
def _known_open(self, fam: str) -> bool:
|
|
648
|
+
"""No base chain covers `fam`, yet every place such rules could live was read."""
|
|
649
|
+
return self.nft_read and (self.ipt_read if fam == "ip" else self.ip6t_read)
|
|
650
|
+
|
|
651
|
+
def _eval_family(self, port: int, proto: str, iface: str, fam: str) -> PortVerdict | None:
|
|
652
|
+
bases = [(chains, c) for chains, c in self.base_chains() if c.family in (fam, "inet")]
|
|
653
|
+
if not bases:
|
|
654
|
+
return None
|
|
655
|
+
explicit_any = False
|
|
656
|
+
for chains, base in bases:
|
|
657
|
+
d = _eval_chain(chains, base, port, proto, iface)
|
|
658
|
+
kind = d.kind if d.kind in ("accept", "drop") else base.policy
|
|
659
|
+
if kind == "accept":
|
|
660
|
+
explicit_any = explicit_any or d.explicit
|
|
661
|
+
continue
|
|
662
|
+
if d.restricted or d.restricted_unknown:
|
|
663
|
+
return PortVerdict(Exposure.RESTRICTED, sources=sorted(set(d.restricted)),
|
|
664
|
+
detail=f"{base.source} {base.name}: accepted only for specific sources")
|
|
665
|
+
reason = "rule" if d.explicit else f"default policy {base.policy}"
|
|
666
|
+
return PortVerdict(Exposure.BLOCKED, detail=f"{base.source} {base.name} drops via {reason}")
|
|
667
|
+
return PortVerdict(Exposure.ACCEPTED, explicit_any,
|
|
668
|
+
detail="accepted by explicit rule" if explicit_any else "accepted by default policy")
|
|
669
|
+
|
|
670
|
+
def evaluate_iface(self, port: int, proto: str, iface: str) -> PortVerdict:
|
|
671
|
+
if not self.readable:
|
|
672
|
+
return PortVerdict(Exposure.UNKNOWN, detail="no readable firewall")
|
|
673
|
+
results: list[PortVerdict] = []
|
|
674
|
+
uncovered: list[str] = []
|
|
675
|
+
for fam in self._families():
|
|
676
|
+
v = self._eval_family(port, proto, iface, fam)
|
|
677
|
+
if v is not None:
|
|
678
|
+
results.append(v)
|
|
679
|
+
elif self._known_open(fam):
|
|
680
|
+
results.append(PortVerdict(Exposure.ACCEPTED, False, detail="no input filtering configured"))
|
|
681
|
+
else:
|
|
682
|
+
uncovered.append("IPv4" if fam == "ip" else "IPv6")
|
|
683
|
+
for v in results:
|
|
684
|
+
if v.state == Exposure.ACCEPTED:
|
|
685
|
+
return v
|
|
686
|
+
if uncovered:
|
|
687
|
+
return PortVerdict(
|
|
688
|
+
Exposure.UNKNOWN,
|
|
689
|
+
detail=f"no readable {'/'.join(uncovered)} filtering rules; a drop in the other family proves nothing",
|
|
690
|
+
)
|
|
691
|
+
for v in results:
|
|
692
|
+
if v.state == Exposure.RESTRICTED:
|
|
693
|
+
return v
|
|
694
|
+
return results[0]
|
|
695
|
+
|
|
696
|
+
def evaluate(self, port: int, proto: str = "tcp") -> PortVerdict:
|
|
697
|
+
"""Is a NEW inbound connection to port from any public interface accepted?"""
|
|
698
|
+
if not self.readable:
|
|
699
|
+
return PortVerdict(Exposure.UNKNOWN, detail="no readable firewall")
|
|
700
|
+
results = [self.evaluate_iface(port, proto, i) for i in self.public_candidates()]
|
|
701
|
+
for want in (Exposure.ACCEPTED, Exposure.UNKNOWN, Exposure.RESTRICTED):
|
|
702
|
+
hits = [r for r in results if r.state == want]
|
|
703
|
+
if hits:
|
|
704
|
+
if want == Exposure.RESTRICTED:
|
|
705
|
+
srcs = sorted({s for r in hits for s in r.sources})
|
|
706
|
+
return PortVerdict(want, sources=srcs, detail=hits[0].detail)
|
|
707
|
+
return hits[0]
|
|
708
|
+
return results[0]
|
|
709
|
+
|
|
710
|
+
def reachable_via_overlay(self, port: int, proto: str = "tcp") -> list[str]:
|
|
711
|
+
"""Overlay interfaces on which the port is accepted."""
|
|
712
|
+
return [i for i in self.overlay_ifaces()
|
|
713
|
+
if self.evaluate_iface(port, proto, i).state == Exposure.ACCEPTED]
|