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.
Files changed (93) hide show
  1. umbermark/__init__.py +3 -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 +13 -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/capture.py +228 -0
  46. umbermark/core/check.py +82 -0
  47. umbermark/core/checkin.py +89 -0
  48. umbermark/core/crypto.py +15 -0
  49. umbermark/core/duration.py +45 -0
  50. umbermark/core/engine.py +74 -0
  51. umbermark/core/external/__init__.py +6 -0
  52. umbermark/core/external/check.py +54 -0
  53. umbermark/core/external/checks/__init__.py +31 -0
  54. umbermark/core/external/checks/exposed_datastores.py +55 -0
  55. umbermark/core/external/checks/http_security_headers.py +249 -0
  56. umbermark/core/external/checks/legacy_services.py +52 -0
  57. umbermark/core/external/checks/open_management_ports.py +65 -0
  58. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  59. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  60. umbermark/core/external/checks/tls_hygiene.py +107 -0
  61. umbermark/core/external/engine.py +89 -0
  62. umbermark/core/external/ownership.py +607 -0
  63. umbermark/core/external/probe.py +103 -0
  64. umbermark/core/external/socket_probe.py +400 -0
  65. umbermark/core/firewall.py +713 -0
  66. umbermark/core/fleet.py +92 -0
  67. umbermark/core/history.py +235 -0
  68. umbermark/core/html_report.py +306 -0
  69. umbermark/core/keyenc.py +42 -0
  70. umbermark/core/labels.py +29 -0
  71. umbermark/core/lifecycle.py +100 -0
  72. umbermark/core/logredact.py +44 -0
  73. umbermark/core/partial.py +57 -0
  74. umbermark/core/pdf/__init__.py +0 -0
  75. umbermark/core/pdf/renderer.py +26 -0
  76. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  77. umbermark/core/platform.py +73 -0
  78. umbermark/core/redact.py +75 -0
  79. umbermark/core/report.py +25 -0
  80. umbermark/core/scoring.py +105 -0
  81. umbermark/core/settings.py +238 -0
  82. umbermark/core/short_id.py +27 -0
  83. umbermark/core/signing.py +24 -0
  84. umbermark/core/ssh.py +156 -0
  85. umbermark/core/ssh_fingerprint.py +16 -0
  86. umbermark/core/tiers.py +128 -0
  87. umbermark/core/webhooks.py +177 -0
  88. umbermark/core/wording.py +371 -0
  89. umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
  90. umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
  91. umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  92. umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  93. 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]