graphical-ssh 0.1.1__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.
gssh/data/gssh.svg ADDED
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
2
+ <!-- Terminal background -->
3
+ <rect x="8" y="16" width="112" height="96" rx="8" fill="#1e1e1e" stroke="#3a3a3a" stroke-width="2"/>
4
+ <!-- Title bar -->
5
+ <rect x="8" y="16" width="112" height="20" rx="8" fill="#2d2d2d"/>
6
+ <rect x="8" y="28" width="112" height="8" fill="#2d2d2d"/>
7
+ <!-- Window dots -->
8
+ <circle cx="20" cy="26" r="4" fill="#e06c75"/>
9
+ <circle cx="34" cy="26" r="4" fill="#e5c07b"/>
10
+ <circle cx="48" cy="26" r="4" fill="#98c379"/>
11
+ <!-- Prompt > -->
12
+ <text x="20" y="64" font-family="monospace" font-size="16" fill="#61afef" font-weight="bold">&gt;</text>
13
+ <!-- ssh text -->
14
+ <text x="36" y="64" font-family="monospace" font-size="14" fill="#abb2bf">ssh</text>
15
+ <!-- Connection indicator -->
16
+ <line x1="20" y1="80" x2="100" y2="80" stroke="#3a3a3a" stroke-width="1"/>
17
+ <circle cx="28" cy="92" r="3" fill="#98c379"/>
18
+ <text x="38" y="96" font-family="monospace" font-size="10" fill="#5c6370">connected</text>
19
+ <!-- Key icon bottom-right -->
20
+ <g transform="translate(88, 88)">
21
+ <circle cx="8" cy="8" r="6" fill="none" stroke="#61afef" stroke-width="2"/>
22
+ <line x1="14" y1="8" x2="24" y2="8" stroke="#61afef" stroke-width="2"/>
23
+ <line x1="20" y1="8" x2="20" y2="12" stroke="#61afef" stroke-width="2"/>
24
+ <line x1="24" y1="8" x2="24" y2="14" stroke="#61afef" stroke-width="2"/>
25
+ </g>
26
+ </svg>
gssh/host_editor.py ADDED
@@ -0,0 +1,174 @@
1
+ """Dialog for adding and editing SSH host entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PyQt6.QtWidgets import (
6
+ QComboBox,
7
+ QDialog,
8
+ QDialogButtonBox,
9
+ QFileDialog,
10
+ QFormLayout,
11
+ QHBoxLayout,
12
+ QLineEdit,
13
+ QPlainTextEdit,
14
+ QPushButton,
15
+ QVBoxLayout,
16
+ QWidget,
17
+ )
18
+
19
+ from .config import HostEntry
20
+
21
+
22
+ class HostEditorDialog(QDialog):
23
+ """Modal dialog for editing a single HostEntry.
24
+
25
+ Pass an existing HostEntry to edit, or None to create a new one.
26
+ Call result() after accept to get the updated HostEntry.
27
+ """
28
+
29
+ def __init__(self, host: HostEntry | None = None, parent: QWidget | None = None) -> None:
30
+ super().__init__(parent)
31
+ self.setWindowTitle("Edit Host" if host else "Add Host")
32
+ self.setMinimumWidth(500)
33
+ self._host = host
34
+ self._build_ui()
35
+ if host:
36
+ self._populate(host)
37
+
38
+ def _build_ui(self) -> None:
39
+ layout = QVBoxLayout(self)
40
+
41
+ form = QFormLayout()
42
+
43
+ self.alias_edit = QLineEdit()
44
+ self.alias_edit.setPlaceholderText("myserver (or *.example.com)")
45
+ form.addRow("Alias / Pattern:", self.alias_edit)
46
+
47
+ self.hostname_edit = QLineEdit()
48
+ self.hostname_edit.setPlaceholderText("192.168.1.100")
49
+ form.addRow("Host Name:", self.hostname_edit)
50
+
51
+ self.user_edit = QLineEdit()
52
+ self.user_edit.setPlaceholderText("root")
53
+ form.addRow("User:", self.user_edit)
54
+
55
+ self.port_edit = QLineEdit()
56
+ self.port_edit.setPlaceholderText("22")
57
+ form.addRow("Port:", self.port_edit)
58
+
59
+ # IdentityFile with browse button
60
+ key_layout = QHBoxLayout()
61
+ self.identity_edit = QLineEdit()
62
+ self.identity_edit.setPlaceholderText("~/.ssh/id_ed25519")
63
+ browse_btn = QPushButton("Browse…")
64
+ browse_btn.clicked.connect(self._browse_key)
65
+ key_layout.addWidget(self.identity_edit)
66
+ key_layout.addWidget(browse_btn)
67
+ form.addRow("Identity File:", key_layout)
68
+
69
+ self.proxyjump_edit = QLineEdit()
70
+ self.proxyjump_edit.setPlaceholderText("jumpbox (optional)")
71
+ form.addRow("Proxy Jump:", self.proxyjump_edit)
72
+
73
+ # ForwardAgent combo
74
+ self.forwardagent_combo = QComboBox()
75
+ self.forwardagent_combo.addItems(["(default)", "yes", "no"])
76
+ form.addRow("Forward Agent:", self.forwardagent_combo)
77
+
78
+ layout.addLayout(form)
79
+
80
+ # Extra options text area
81
+ self.extra_edit = QPlainTextEdit()
82
+ self.extra_edit.setPlaceholderText("One option per line:\nCompression yes\nServerAliveInterval 60")
83
+ self.extra_edit.setMaximumHeight(120)
84
+ form2 = QFormLayout()
85
+ form2.addRow("Extra Options:", self.extra_edit)
86
+ layout.addLayout(form2)
87
+
88
+ # Buttons
89
+ buttons = QDialogButtonBox(
90
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
91
+ )
92
+ buttons.accepted.connect(self._on_accept)
93
+ buttons.rejected.connect(self.reject)
94
+ layout.addWidget(buttons)
95
+
96
+ def _browse_key(self) -> None:
97
+ start_dir = self.identity_edit.text() or "~/.ssh"
98
+ path, _ = QFileDialog.getOpenFileName(
99
+ self, "Select SSH Key File", start_dir, "All Files (*)"
100
+ )
101
+ if path:
102
+ self.identity_edit.setText(path)
103
+
104
+ def _populate(self, host: HostEntry) -> None:
105
+ self.alias_edit.setText(host.pattern)
106
+ self.hostname_edit.setText(host.hostname)
107
+ self.user_edit.setText(host.user)
108
+ self.port_edit.setText(host.port)
109
+ self.identity_edit.setText(host.identity_file)
110
+ self.proxyjump_edit.setText(host.proxy_jump)
111
+
112
+ fa = host.forward_agent.lower()
113
+ idx = self.forwardagent_combo.findText(fa if fa in ("yes", "no") else "(default)")
114
+ self.forwardagent_combo.setCurrentIndex(max(0, idx))
115
+
116
+ extras = host.extra_options()
117
+ if extras:
118
+ lines = [f"{k} {v}" for k, v in extras.items()]
119
+ self.extra_edit.setPlainText("\n".join(lines))
120
+
121
+ def _on_accept(self) -> None:
122
+ alias = self.alias_edit.text().strip()
123
+ if not alias:
124
+ self.alias_edit.setFocus()
125
+ return
126
+ self.accept()
127
+
128
+ def result_host(self) -> HostEntry:
129
+ """Build a HostEntry from the dialog fields. Call after accept()."""
130
+ options: dict[str, str] = {}
131
+
132
+ hostname = self.hostname_edit.text().strip()
133
+ if hostname:
134
+ options["hostname"] = hostname
135
+
136
+ user = self.user_edit.text().strip()
137
+ if user:
138
+ options["user"] = user
139
+
140
+ port = self.port_edit.text().strip()
141
+ if port:
142
+ options["port"] = port
143
+
144
+ identity = self.identity_edit.text().strip()
145
+ if identity:
146
+ options["identityfile"] = identity
147
+
148
+ proxy = self.proxyjump_edit.text().strip()
149
+ if proxy:
150
+ options["proxyjump"] = proxy
151
+
152
+ fa = self.forwardagent_combo.currentText()
153
+ if fa in ("yes", "no"):
154
+ options["forwardagent"] = fa
155
+
156
+ # Parse extra options
157
+ for line in self.extra_edit.toPlainText().splitlines():
158
+ stripped = line.strip()
159
+ if not stripped or stripped.startswith("#"):
160
+ continue
161
+ parts = stripped.split(None, 1)
162
+ if len(parts) == 2:
163
+ options[parts[0].lower()] = parts[1].strip()
164
+ elif len(parts) == 1:
165
+ options[parts[0].lower()] = ""
166
+
167
+ # Preserve raw_trailing if editing
168
+ trailing = self._host.raw_trailing if self._host else []
169
+
170
+ return HostEntry(
171
+ pattern=self.alias_edit.text().strip(),
172
+ options=options,
173
+ raw_trailing=trailing,
174
+ )
gssh/linter.py ADDED
@@ -0,0 +1,402 @@
1
+ """SSH config linter — catches errors no GUI manager checks.
2
+
3
+ Validates ~/.ssh/config entries for common mistakes: unknown options,
4
+ deprecated directives, missing or insecure identity files, port range
5
+ issues, ProxyJump references to undefined hosts, and overlapping Host
6
+ patterns that cause first-match-wins conflicts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from .config import HostEntry, SSHConfig
18
+
19
+ __all__ = ["LintFinding", "lint_config", "severity_icon"]
20
+
21
+
22
+ # Known ssh_config options — generated from `ssh -G` on OpenSSH 10.0p2
23
+ # plus options that only work in config files (Include, Match, ControlPath,
24
+ # LocalForward, RemoteForward, DynamicForward, ProxyJump, ProxyCommand).
25
+ _KNOWN_OPTIONS = {
26
+ # Core connection
27
+ "host", "hostname", "user", "port", "addressfamily",
28
+ "bindaddress", "connecttimeout", "connectionattempts",
29
+ # Identity and auth
30
+ "identityfile", "identityagent", "certificatefile",
31
+ "identitiesonly", "addkeystoagent",
32
+ "pubkeyauthentication", "pubkeyacceptedalgorithms",
33
+ "hostbasedauthentication", "hostbasedacceptedalgorithms",
34
+ "passwordauthentication", "kbdinteractiveauthentication",
35
+ "kbdinteractivedevices", "numberofpasswordprompts",
36
+ "preferredauthentications", "requiredrsasize",
37
+ "casignaturealgorithms", "securitykeyprovider",
38
+ # Proxy and jumping
39
+ "proxyjump", "proxycommand", "proxyusefdpass",
40
+ # Forwarding
41
+ "forwardagent", "localforward", "remoteforward",
42
+ "dynamicforward", "gatewayports", "exitonforwardfailure",
43
+ "clearallforwardings", "permitremoteopen",
44
+ # X11
45
+ "forwardx11", "forwardx11trusted", "forwardx11timeout",
46
+ "xauthlocation",
47
+ # Control master
48
+ "controlmaster", "controlpath", "controlpersist",
49
+ # Host key checking
50
+ "stricthostkeychecking", "userknownhostsfile",
51
+ "globalknownhostsfile", "checkhostip", "verifyhostkeydns",
52
+ "visualhostkey", "updatehostkeys", "revokedhostkeys",
53
+ # Keepalive and timeout
54
+ "serveraliveinterval", "serveralivecountmax",
55
+ "tcpkeepalive",
56
+ # Compression
57
+ "compression",
58
+ # GSSAPI
59
+ "gssapiauthentication", "gssapidelegatecredentials",
60
+ "gssapikexalgorithms", "gssapikeyexchange",
61
+ "gssapirenewalforcesrekey", "gssapitrustdns",
62
+ # Canonicalization
63
+ "canonicalizehostname", "canonicaldomains",
64
+ "canonicalizefallbacklocal", "canonicalizemaxdots",
65
+ # Crypto
66
+ "ciphers", "macs", "kexalgorithms",
67
+ "hostkeyalgorithms",
68
+ # Logging
69
+ "loglevel", "logverbose", "syslogfacility",
70
+ # Session
71
+ "requesttty", "sessiontype", "permitlocalcommand",
72
+ "localcommand", "remotecommand", "stdinnull",
73
+ "escapechar", "enableescapecommandline",
74
+ # Tunnel
75
+ "tunnel", "tunneldevice",
76
+ # Fork
77
+ "forkafterauthentication",
78
+ # Misc
79
+ "batchmode", "hashknownhosts", "nohostauthenticationforlocalhost",
80
+ "enablesshkeysign", "fingerprinthash", "ipqos",
81
+ "obscurekeystroketiming", "rekeylimit", "channeltimeout",
82
+ "sendenv", "setenv", "tag",
83
+ "streamlocalbindmask", "streamlocalbindunlink",
84
+ # Config-file-only directives (not in ssh -G output)
85
+ "include", "match",
86
+ }
87
+
88
+ # Deprecated options (removed or superseded in modern OpenSSH)
89
+ _DEPRECATED_OPTIONS = {
90
+ "cipher": "Use 'Ciphers' instead (plural).",
91
+ "protocol": "Protocol option is deprecated; SSHv1 is no longer supported.",
92
+ "fallbacktotrusted": "Deprecated; use 'ForwardX11Trusted'.",
93
+ "rsaauthentication": "Deprecated; use 'PubkeyAuthentication'.",
94
+ "rhostsrsaauthentication": "Removed in OpenSSH 8.0.",
95
+ "challengeresponseauthentication": "Deprecated; use 'KbdInteractiveAuthentication'.",
96
+ "useprivilegedport": "Deprecated.",
97
+ "compressionlevel": "Deprecated; Compression is a yes/no option now.",
98
+ }
99
+
100
+ # Options where "none" is a valid value meaning "disable"
101
+ _NONE_VALUE_OPTIONS = {"identityfile", "proxyjump", "certificatefile",
102
+ "remotecommand", "localcommand"}
103
+
104
+
105
+ @dataclass
106
+ class LintFinding:
107
+ """A single lint finding for an SSH config entry."""
108
+ severity: str # "error" | "warning" | "info"
109
+ host_alias: str
110
+ option: str
111
+ message: str
112
+ suggestion: str = ""
113
+
114
+
115
+ def severity_icon(severity: str) -> str:
116
+ """Return a display icon for a severity level."""
117
+ if severity == "error":
118
+ return "✗"
119
+ if severity == "warning":
120
+ return "⚠"
121
+ return "ℹ"
122
+
123
+
124
+ def lint_config(config: SSHConfig, config_path: Path | None = None) -> list[LintFinding]:
125
+ """Lint an SSHConfig and return all findings.
126
+
127
+ Runs all checks and returns a list sorted by severity (errors first).
128
+ """
129
+ findings: list[LintFinding] = []
130
+
131
+ all_aliases = {h.alias for h in config.hosts}
132
+
133
+ for host in config.hosts:
134
+ findings.extend(_check_unknown_options(host))
135
+ findings.extend(_check_deprecated_options(host))
136
+ findings.extend(_check_identity_file(host, config_path))
137
+ findings.extend(_check_port(host))
138
+ findings.extend(_check_proxyjump(host, all_aliases))
139
+
140
+ findings.extend(_check_pattern_overlaps(config))
141
+
142
+ # Sort: errors first, then warnings, then info
143
+ severity_order = {"error": 0, "warning": 1, "info": 2}
144
+ findings.sort(key=lambda f: (severity_order.get(f.severity, 3), f.host_alias))
145
+ return findings
146
+
147
+
148
+ def _check_unknown_options(host: HostEntry) -> list[LintFinding]:
149
+ """Check for options not in the known ssh_config set."""
150
+ findings = []
151
+ for key in host.options:
152
+ # Skip deprecated options — they're checked separately
153
+ if key in _DEPRECATED_OPTIONS:
154
+ continue
155
+ if key not in _KNOWN_OPTIONS:
156
+ findings.append(LintFinding(
157
+ severity="warning",
158
+ host_alias=host.alias,
159
+ option=key,
160
+ message=f"Unknown option '{key}' — may be ignored by ssh.",
161
+ suggestion="Check ssh_config(5) for valid options.",
162
+ ))
163
+ return findings
164
+
165
+
166
+ def _check_deprecated_options(host: HostEntry) -> list[LintFinding]:
167
+ """Check for deprecated options."""
168
+ findings = []
169
+ for key in host.options:
170
+ if key in _DEPRECATED_OPTIONS:
171
+ findings.append(LintFinding(
172
+ severity="warning",
173
+ host_alias=host.alias,
174
+ option=key,
175
+ message=f"Deprecated option '{key}'.",
176
+ suggestion=_DEPRECATED_OPTIONS[key],
177
+ ))
178
+ return findings
179
+
180
+
181
+ def _check_identity_file(host: HostEntry, config_path: Path | None) -> list[LintFinding]:
182
+ """Check IdentityFile exists and has secure permissions."""
183
+ findings = []
184
+ idfile = host.identity_file
185
+ if not idfile:
186
+ return findings
187
+
188
+ # "none" is a valid value meaning "don't use an identity file"
189
+ if idfile.lower() == "none":
190
+ return findings
191
+
192
+ expanded = os.path.expanduser(idfile)
193
+
194
+ if not os.path.exists(expanded):
195
+ findings.append(LintFinding(
196
+ severity="warning",
197
+ host_alias=host.alias,
198
+ option="identityfile",
199
+ message=f"IdentityFile '{idfile}' does not exist.",
200
+ suggestion="Generate a key with ssh-keygen or fix the path.",
201
+ ))
202
+ return findings
203
+
204
+ # Check permissions — private key should not be group/world readable
205
+ try:
206
+ st = os.stat(expanded)
207
+ if st.st_mode & 0o077:
208
+ findings.append(LintFinding(
209
+ severity="error",
210
+ host_alias=host.alias,
211
+ option="identityfile",
212
+ message=f"IdentityFile '{idfile}' has insecure permissions ({oct(st.st_mode & 0o777)}).",
213
+ suggestion="Run: chmod 600 <keyfile>",
214
+ ))
215
+ except OSError:
216
+ pass
217
+
218
+ return findings
219
+
220
+
221
+ def _check_port(host: HostEntry) -> list[LintFinding]:
222
+ """Check port is a valid number in range."""
223
+ findings = []
224
+ port = host.port
225
+ if not port:
226
+ return findings
227
+
228
+ try:
229
+ p = int(port)
230
+ if p < 1 or p > 65535:
231
+ findings.append(LintFinding(
232
+ severity="error",
233
+ host_alias=host.alias,
234
+ option="port",
235
+ message=f"Port {port} is out of range (1-65535).",
236
+ ))
237
+ except ValueError:
238
+ findings.append(LintFinding(
239
+ severity="error",
240
+ host_alias=host.alias,
241
+ option="port",
242
+ message=f"Port '{port}' is not a valid number.",
243
+ ))
244
+ return findings
245
+
246
+
247
+ # IPv6 address regex (simplified — matches [2001:db8::1] or bare 2001:db8::1)
248
+ _IPV6_RE = re.compile(r"^\[?[0-9a-fA-F:]+\]?$")
249
+
250
+
251
+ def _check_proxyjump(host: HostEntry, all_aliases: set[str]) -> list[LintFinding]:
252
+ """Check ProxyJump references a defined host."""
253
+ findings = []
254
+ proxy = host.proxy_jump
255
+ if not proxy:
256
+ return findings
257
+
258
+ # "none" disables proxyjump
259
+ if proxy.lower() == "none":
260
+ return findings
261
+
262
+ # ProxyJump can be a chain: jump1,jump2,...
263
+ # Each can be: hostalias, user@host, host:port, user@host:port,
264
+ # [ipv6]:port, ssh://user@host:port
265
+ jumps = proxy.split(",")
266
+ for jump in jumps:
267
+ jump = jump.strip()
268
+ jump_host = _parse_proxyjump_host(jump)
269
+ if not jump_host:
270
+ continue
271
+ if jump_host in all_aliases:
272
+ continue
273
+ # It might be a real hostname (not an alias) — that's fine
274
+ # Only warn if it looks like it should be an alias
275
+ if "*" not in jump_host and not _looks_like_hostname(jump_host):
276
+ findings.append(LintFinding(
277
+ severity="info",
278
+ host_alias=host.alias,
279
+ option="proxyjump",
280
+ message=f"ProxyJump host '{jump_host}' is not defined in ssh_config.",
281
+ suggestion=f"Add a 'Host {jump_host}' entry or verify the hostname.",
282
+ ))
283
+ return findings
284
+
285
+
286
+ def _parse_proxyjump_host(jump: str) -> str:
287
+ """Extract the hostname from a ProxyJump destination string.
288
+
289
+ Handles: hostalias, user@host, host:port, user@host:port,
290
+ [ipv6]:port, user@[ipv6]:port, ssh://user@host:port
291
+ """
292
+ # Strip ssh:// prefix
293
+ if jump.startswith("ssh://"):
294
+ jump = jump[6:]
295
+ # Strip user@
296
+ if "@" in jump:
297
+ jump = jump.split("@", 1)[1]
298
+ # Handle bracketed IPv6: [2001:db8::1] or [2001:db8::1]:2222
299
+ if jump.startswith("["):
300
+ end = jump.find("]")
301
+ if end != -1:
302
+ return jump[1:end]
303
+ return jump[1:] # malformed, but extract what we can
304
+ # Handle IPv6 without brackets (no dots, multiple colons)
305
+ if jump.count(":") >= 2:
306
+ return jump
307
+ # Handle host:port
308
+ if ":" in jump:
309
+ return jump.split(":")[0]
310
+ return jump
311
+
312
+
313
+ def _looks_like_hostname(name: str) -> bool:
314
+ """Heuristic: does this look like a real hostname rather than an alias?"""
315
+ if "." in name:
316
+ return True # FQDN or IP
317
+ # IPv6
318
+ if ":" in name:
319
+ return True
320
+ # Check if it's a numeric IP (4 octets)
321
+ parts = name.split(".")
322
+ if len(parts) == 4:
323
+ try:
324
+ for p in parts:
325
+ if 0 <= int(p) <= 255:
326
+ continue
327
+ return False
328
+ return True
329
+ except ValueError:
330
+ pass
331
+ return False
332
+
333
+
334
+ def _check_pattern_overlaps(config: SSHConfig) -> list[LintFinding]:
335
+ """Check for overlapping Host patterns that cause first-match-wins conflicts.
336
+
337
+ Only warns when a *less specific* pattern appears *before* a *more specific*
338
+ one with conflicting options — this is the actual first-match-wins trap.
339
+ The standard idiom (specific hosts first, `Host *` catch-all last) is
340
+ correctly ordered and will NOT produce a warning.
341
+ """
342
+ findings = []
343
+ hosts = config.hosts
344
+
345
+ for i, h1 in enumerate(hosts):
346
+ for j, h2 in enumerate(hosts):
347
+ if i >= j:
348
+ continue
349
+ # h1 is earlier in the file than h2.
350
+ # Only warn if h1 is *less specific* than h2 (h1's pattern
351
+ # is a superset of h2's). This catches the real trap:
352
+ # `Host *` before `Host server` means server's options are
353
+ # shadowed. The reverse (specific first, wildcard last) is
354
+ # the correct idiom and should not warn.
355
+ patterns1 = h1.pattern.split()
356
+ patterns2 = h2.pattern.split()
357
+ for p1 in patterns1:
358
+ for p2 in patterns2:
359
+ if p1.startswith("!"):
360
+ continue
361
+ if p2.startswith("!"):
362
+ continue
363
+ # Only warn if p1 is broader than p2
364
+ if not _is_broader_pattern(p1, p2):
365
+ continue
366
+ # Check if they have conflicting options
367
+ shared_keys = set(h1.options.keys()) & set(h2.options.keys())
368
+ conflicts = [k for k in shared_keys if h1.options[k] != h2.options[k]]
369
+ if conflicts:
370
+ findings.append(LintFinding(
371
+ severity="info",
372
+ host_alias=h1.alias,
373
+ option="host",
374
+ message=(
375
+ f"Host pattern '{p1}' (earlier) is broader than "
376
+ f"'{p2}' (in '{h2.alias}') with conflicting options: "
377
+ f"{', '.join(conflicts)}. ssh uses first-match-wins."
378
+ ),
379
+ suggestion="Reorder: put specific hosts before wildcards.",
380
+ ))
381
+ return findings
382
+
383
+
384
+ def _is_broader_pattern(broad: str, specific: str) -> bool:
385
+ """Check if *broad* is a superset pattern of *specific*.
386
+
387
+ `*` is broader than everything. `*.example.com` is broader than
388
+ `server.example.com`. `server` is not broader than `server`.
389
+ """
390
+ if broad == "*":
391
+ return broad != specific
392
+ if broad == specific:
393
+ return False
394
+ if "*" in broad:
395
+ # broad is a wildcard — does specific match it?
396
+ if fnmatch.fnmatch(specific, broad):
397
+ return True
398
+ # Try a test name from the wildcard
399
+ test_name = broad.replace("*", "test")
400
+ if fnmatch.fnmatch(test_name, specific):
401
+ return True
402
+ return False