sshcfg-lint 0.1.0__tar.gz
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.
- sshcfg_lint-0.1.0/LICENSE +9 -0
- sshcfg_lint-0.1.0/PKG-INFO +39 -0
- sshcfg_lint-0.1.0/README.md +25 -0
- sshcfg_lint-0.1.0/pyproject.toml +25 -0
- sshcfg_lint-0.1.0/setup.cfg +4 -0
- sshcfg_lint-0.1.0/sshcfg_lint.egg-info/PKG-INFO +39 -0
- sshcfg_lint-0.1.0/sshcfg_lint.egg-info/SOURCES.txt +10 -0
- sshcfg_lint-0.1.0/sshcfg_lint.egg-info/dependency_links.txt +1 -0
- sshcfg_lint-0.1.0/sshcfg_lint.egg-info/entry_points.txt +2 -0
- sshcfg_lint-0.1.0/sshcfg_lint.egg-info/top_level.txt +1 -0
- sshcfg_lint-0.1.0/sshcfg_lint.py +158 -0
- sshcfg_lint-0.1.0/tests/test_lint.py +72 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agent-one
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshcfg-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lint ~/.ssh/config: options shadowed by earlier Host blocks, duplicate hosts, missing/loose key files, ProxyJump loops. Stdlib only.
|
|
5
|
+
Author-email: agent-one <rafal.baranowski.iot@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rafal-one/sshcfg-lint
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# sshcfg-lint
|
|
16
|
+
|
|
17
|
+
Lint your OpenSSH client config (`~/.ssh/config`). In ssh_config the **first** value found for an option wins, so a `Host *` block placed above a specific host silently overrides it. sshcfg-lint finds that and other common mistakes. Stdlib only, Python 3.9+.
|
|
18
|
+
|
|
19
|
+
## Checks
|
|
20
|
+
- an option in a `Host x` block overridden by an earlier matching block (the classic `Host *` / `User` trap)
|
|
21
|
+
- duplicate `Host` patterns and options repeated within one block
|
|
22
|
+
- `IdentityFile`/`CertificateFile` that do not exist; private keys readable by others; config writable by others (ssh refuses these)
|
|
23
|
+
- `ProxyJump` loops or hops with no matching `Host`
|
|
24
|
+
- deprecated options (`UseRoaming`, `Protocol`, ...)
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
```console
|
|
28
|
+
$ sshcfg-lint
|
|
29
|
+
/home/me/.ssh/config:7: warn: user 'deploy' for web is overridden by 'root' at line 2 (Host * (line 1)); first match wins
|
|
30
|
+
/home/me/.ssh/config:12: error: ~/.ssh/id_work is accessible by others (mode 0o644); ssh will refuse it
|
|
31
|
+
$ sshcfg-lint --strict other_config # exit 1 on warnings too
|
|
32
|
+
```
|
|
33
|
+
Options: `--no-files` (skip disk checks), `--strict`. Exit code 1 on errors, 2 if the file is unreadable. Not checked: `Match` blocks and `Include`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
`pip install sshcfg-lint` (once published) or copy `sshcfg_lint.py`.
|
|
37
|
+
|
|
38
|
+
## Support
|
|
39
|
+
MIT licensed. If it saves you time: https://paypal.me/RafalBaranowski
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# sshcfg-lint
|
|
2
|
+
|
|
3
|
+
Lint your OpenSSH client config (`~/.ssh/config`). In ssh_config the **first** value found for an option wins, so a `Host *` block placed above a specific host silently overrides it. sshcfg-lint finds that and other common mistakes. Stdlib only, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
- an option in a `Host x` block overridden by an earlier matching block (the classic `Host *` / `User` trap)
|
|
7
|
+
- duplicate `Host` patterns and options repeated within one block
|
|
8
|
+
- `IdentityFile`/`CertificateFile` that do not exist; private keys readable by others; config writable by others (ssh refuses these)
|
|
9
|
+
- `ProxyJump` loops or hops with no matching `Host`
|
|
10
|
+
- deprecated options (`UseRoaming`, `Protocol`, ...)
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
```console
|
|
14
|
+
$ sshcfg-lint
|
|
15
|
+
/home/me/.ssh/config:7: warn: user 'deploy' for web is overridden by 'root' at line 2 (Host * (line 1)); first match wins
|
|
16
|
+
/home/me/.ssh/config:12: error: ~/.ssh/id_work is accessible by others (mode 0o644); ssh will refuse it
|
|
17
|
+
$ sshcfg-lint --strict other_config # exit 1 on warnings too
|
|
18
|
+
```
|
|
19
|
+
Options: `--no-files` (skip disk checks), `--strict`. Exit code 1 on errors, 2 if the file is unreadable. Not checked: `Match` blocks and `Include`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
`pip install sshcfg-lint` (once published) or copy `sshcfg_lint.py`.
|
|
23
|
+
|
|
24
|
+
## Support
|
|
25
|
+
MIT licensed. If it saves you time: https://paypal.me/RafalBaranowski
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sshcfg-lint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lint ~/.ssh/config: options shadowed by earlier Host blocks, duplicate hosts, missing/loose key files, ProxyJump loops. Stdlib only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "agent-one", email = "rafal.baranowski.iot@gmail.com"}]
|
|
13
|
+
classifiers = ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/rafal-one/sshcfg-lint"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
sshcfg-lint = "sshcfg_lint:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
py-modules = ["sshcfg_lint"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshcfg-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lint ~/.ssh/config: options shadowed by earlier Host blocks, duplicate hosts, missing/loose key files, ProxyJump loops. Stdlib only.
|
|
5
|
+
Author-email: agent-one <rafal.baranowski.iot@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rafal-one/sshcfg-lint
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# sshcfg-lint
|
|
16
|
+
|
|
17
|
+
Lint your OpenSSH client config (`~/.ssh/config`). In ssh_config the **first** value found for an option wins, so a `Host *` block placed above a specific host silently overrides it. sshcfg-lint finds that and other common mistakes. Stdlib only, Python 3.9+.
|
|
18
|
+
|
|
19
|
+
## Checks
|
|
20
|
+
- an option in a `Host x` block overridden by an earlier matching block (the classic `Host *` / `User` trap)
|
|
21
|
+
- duplicate `Host` patterns and options repeated within one block
|
|
22
|
+
- `IdentityFile`/`CertificateFile` that do not exist; private keys readable by others; config writable by others (ssh refuses these)
|
|
23
|
+
- `ProxyJump` loops or hops with no matching `Host`
|
|
24
|
+
- deprecated options (`UseRoaming`, `Protocol`, ...)
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
```console
|
|
28
|
+
$ sshcfg-lint
|
|
29
|
+
/home/me/.ssh/config:7: warn: user 'deploy' for web is overridden by 'root' at line 2 (Host * (line 1)); first match wins
|
|
30
|
+
/home/me/.ssh/config:12: error: ~/.ssh/id_work is accessible by others (mode 0o644); ssh will refuse it
|
|
31
|
+
$ sshcfg-lint --strict other_config # exit 1 on warnings too
|
|
32
|
+
```
|
|
33
|
+
Options: `--no-files` (skip disk checks), `--strict`. Exit code 1 on errors, 2 if the file is unreadable. Not checked: `Match` blocks and `Include`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
`pip install sshcfg-lint` (once published) or copy `sshcfg_lint.py`.
|
|
37
|
+
|
|
38
|
+
## Support
|
|
39
|
+
MIT licensed. If it saves you time: https://paypal.me/RafalBaranowski
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
sshcfg_lint.py
|
|
5
|
+
sshcfg_lint.egg-info/PKG-INFO
|
|
6
|
+
sshcfg_lint.egg-info/SOURCES.txt
|
|
7
|
+
sshcfg_lint.egg-info/dependency_links.txt
|
|
8
|
+
sshcfg_lint.egg-info/entry_points.txt
|
|
9
|
+
sshcfg_lint.egg-info/top_level.txt
|
|
10
|
+
tests/test_lint.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sshcfg_lint
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Lint an OpenSSH client config (~/.ssh/config): shadowed options, missing keys, loose permissions."""
|
|
2
|
+
import argparse
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
DEPRECATED = {
|
|
12
|
+
"useroaming": "removed in OpenSSH 7.x, ignored",
|
|
13
|
+
"protocol": "SSH protocol 1 support was removed",
|
|
14
|
+
"rsaauthentication": "protocol 1 only, removed",
|
|
15
|
+
"rhostsrsaauthentication": "protocol 1 only, removed",
|
|
16
|
+
"useprivilegedport": "removed",
|
|
17
|
+
}
|
|
18
|
+
# first value wins in ssh_config; these accumulate instead
|
|
19
|
+
MULTI = {"identityfile", "localforward", "remoteforward", "dynamicforward", "sendenv",
|
|
20
|
+
"setenv", "certificatefile", "canonicaldomains", "permitremoteopen"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Entry:
|
|
24
|
+
def __init__(self, line, key, value):
|
|
25
|
+
self.line, self.key, self.value = line, key, value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Block:
|
|
29
|
+
def __init__(self, line, patterns):
|
|
30
|
+
self.line, self.patterns, self.entries = line, patterns, []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def split_args(s):
|
|
34
|
+
return [a.strip('"') for a in re.findall(r'"[^"]*"|\S+', s)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(text):
|
|
38
|
+
"""Return (blocks, notes). Block 0 holds options before any Host/Match."""
|
|
39
|
+
blocks = [Block(0, ["*"])]
|
|
40
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
41
|
+
s = raw.strip()
|
|
42
|
+
if not s or s.startswith("#"):
|
|
43
|
+
continue
|
|
44
|
+
m = re.match(r"(\w+)\s*(?:=\s*|\s+)(.*)$", s)
|
|
45
|
+
if not m:
|
|
46
|
+
continue
|
|
47
|
+
key, val = m.group(1).lower(), m.group(2).strip()
|
|
48
|
+
if key == "host":
|
|
49
|
+
blocks.append(Block(n, split_args(val)))
|
|
50
|
+
elif key == "match":
|
|
51
|
+
blocks.append(Block(n, ["<match>"]))
|
|
52
|
+
else:
|
|
53
|
+
blocks[-1].entries.append(Entry(n, key, val))
|
|
54
|
+
return blocks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def matches(patterns, host):
|
|
58
|
+
ok = False
|
|
59
|
+
for p in patterns:
|
|
60
|
+
if p.startswith("!"):
|
|
61
|
+
if fnmatch.fnmatchcase(host, p[1:]):
|
|
62
|
+
return False
|
|
63
|
+
elif fnmatch.fnmatchcase(host, p):
|
|
64
|
+
ok = True
|
|
65
|
+
return ok
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lint(text, config_path=None, check_files=True):
|
|
69
|
+
"""Return a list of (line, level, message)."""
|
|
70
|
+
out = []
|
|
71
|
+
blocks = parse(text)
|
|
72
|
+
seen = {}
|
|
73
|
+
for b in blocks[1:]:
|
|
74
|
+
for p in b.patterns:
|
|
75
|
+
if p in seen and not p.startswith("!") and p != "<match>":
|
|
76
|
+
out.append((b.line, "warn", f"Host {p!r} already defined at line {seen[p]}"))
|
|
77
|
+
seen.setdefault(p, b.line)
|
|
78
|
+
for b in blocks:
|
|
79
|
+
for e in b.entries:
|
|
80
|
+
if e.key in DEPRECATED:
|
|
81
|
+
out.append((e.line, "warn", f"{e.key}: {DEPRECATED[e.key]}"))
|
|
82
|
+
keys = {}
|
|
83
|
+
for e in b.entries:
|
|
84
|
+
if e.key in keys and e.key not in MULTI:
|
|
85
|
+
out.append((e.line, "warn", f"{e.key} repeated in the same block (line {keys[e.key]}); first value wins"))
|
|
86
|
+
keys.setdefault(e.key, e.line)
|
|
87
|
+
# shadowing: an earlier block that matches the later block's literal host sets the option first
|
|
88
|
+
for i, b in enumerate(blocks):
|
|
89
|
+
if i == 0:
|
|
90
|
+
continue
|
|
91
|
+
hosts = [p for p in b.patterns if not re.search(r"[*?!]", p) and p != "<match>"]
|
|
92
|
+
for e in b.entries:
|
|
93
|
+
if e.key in MULTI:
|
|
94
|
+
continue
|
|
95
|
+
for h in hosts:
|
|
96
|
+
for prev in blocks[:i]:
|
|
97
|
+
if prev is b or "<match>" in prev.patterns or not matches(prev.patterns, h):
|
|
98
|
+
continue
|
|
99
|
+
hit = next((x for x in prev.entries if x.key == e.key), None)
|
|
100
|
+
if hit and hit.value != e.value:
|
|
101
|
+
where = "before any Host" if prev.line == 0 else f"Host {' '.join(prev.patterns)} (line {prev.line})"
|
|
102
|
+
out.append((e.line, "warn",
|
|
103
|
+
f"{e.key} {e.value!r} for {h} is overridden by {hit.value!r} at line {hit.line} ({where}); first match wins"))
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
continue
|
|
107
|
+
break
|
|
108
|
+
names = {p for b in blocks[1:] for p in b.patterns}
|
|
109
|
+
for b in blocks:
|
|
110
|
+
for e in b.entries:
|
|
111
|
+
if e.key == "proxyjump":
|
|
112
|
+
for hop in e.value.split(","):
|
|
113
|
+
hop = hop.split("@")[-1].rsplit(":", 1)[0] if "[" not in hop else hop
|
|
114
|
+
if hop.lower() != "none" and hop not in names and not any(
|
|
115
|
+
matches(x.patterns, hop) for x in blocks[1:] if x is not b):
|
|
116
|
+
out.append((e.line, "info", f"ProxyJump {hop!r} has no matching Host entry"))
|
|
117
|
+
if hop in b.patterns:
|
|
118
|
+
out.append((e.line, "error", f"ProxyJump {hop!r} points at its own block (loop)"))
|
|
119
|
+
if check_files and e.key in ("identityfile", "certificatefile", "userknownhostsfile") and "%" not in e.value:
|
|
120
|
+
for v in split_args(e.value):
|
|
121
|
+
path = os.path.expanduser(v)
|
|
122
|
+
if e.key == "userknownhostsfile" or v.startswith("~") is False and not os.path.isabs(path):
|
|
123
|
+
continue
|
|
124
|
+
if not os.path.exists(path):
|
|
125
|
+
out.append((e.line, "warn", f"{e.key} {v} does not exist"))
|
|
126
|
+
elif e.key == "identityfile" and os.stat(path).st_mode & 0o077:
|
|
127
|
+
out.append((e.line, "error", f"{v} is accessible by others (mode {oct(stat.S_IMODE(os.stat(path).st_mode))}); ssh will refuse it"))
|
|
128
|
+
if config_path and os.path.exists(config_path):
|
|
129
|
+
mode = os.stat(config_path).st_mode
|
|
130
|
+
if mode & 0o022:
|
|
131
|
+
out.append((0, "error", f"{config_path} is writable by group/others (mode {oct(stat.S_IMODE(mode))}); ssh refuses it"))
|
|
132
|
+
return sorted(out, key=lambda x: x[0])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv=None):
|
|
136
|
+
ap = argparse.ArgumentParser(prog="sshcfg-lint", description=__doc__)
|
|
137
|
+
ap.add_argument("file", nargs="?", default=os.path.expanduser("~/.ssh/config"))
|
|
138
|
+
ap.add_argument("--no-files", action="store_true", help="do not check key files on disk")
|
|
139
|
+
ap.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
|
|
140
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
141
|
+
a = ap.parse_args(argv)
|
|
142
|
+
try:
|
|
143
|
+
with open(a.file, encoding="utf-8", errors="replace") as f:
|
|
144
|
+
text = f.read()
|
|
145
|
+
except OSError as ex:
|
|
146
|
+
print(f"sshcfg-lint: {ex}", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
res = lint(text, a.file, not a.no_files)
|
|
149
|
+
for line, lvl, msg in res:
|
|
150
|
+
print(f"{a.file}:{line}: {lvl}: {msg}")
|
|
151
|
+
bad = any(l == "error" or (a.strict and l == "warn") for _, l, _ in res)
|
|
152
|
+
if not res:
|
|
153
|
+
print("ok")
|
|
154
|
+
return 1 if bad else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from sshcfg_lint import lint, main
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def msgs(text, **kw):
|
|
5
|
+
return [m for _, _, m in lint(text, check_files=False, **kw)]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_clean():
|
|
9
|
+
assert lint("Host a\n User x\nHost *\n ForwardAgent no\n", check_files=False) == []
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_shadowed_by_star_first():
|
|
13
|
+
r = msgs("Host *\n User root\nHost web\n User deploy\n")
|
|
14
|
+
assert any("overridden" in m and "root" in m for m in r)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_same_value_not_shadowed():
|
|
18
|
+
assert msgs("Host *\n User x\nHost web\n User x\n") == []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_identityfile_accumulates():
|
|
22
|
+
assert msgs("Host *\n IdentityFile ~/a\nHost web\n IdentityFile ~/b\n") == []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_duplicate_host():
|
|
26
|
+
assert any("already defined" in m for m in msgs("Host a\n User x\nHost a\n User y\n"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_repeated_key_in_block():
|
|
30
|
+
assert any("repeated" in m for m in msgs("Host a\n User x\n User y\n"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_deprecated():
|
|
34
|
+
assert any("UseRoaming".lower() in m for m in msgs("Host a\n UseRoaming no\n"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_proxyjump_unknown_and_loop():
|
|
38
|
+
r = lint("Host a\n ProxyJump a\nHost b\n ProxyJump nope\n", check_files=False)
|
|
39
|
+
assert any(l == "error" for _, l, _ in r)
|
|
40
|
+
assert any("nope" in m for _, _, m in r)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_equals_syntax_and_negation():
|
|
44
|
+
r = msgs("Host *\n User=root\nHost web\n User=deploy\n")
|
|
45
|
+
assert any("overridden" in m for m in r)
|
|
46
|
+
assert msgs("Host * !web\n User root\nHost web\n User deploy\n") == []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_key_permissions(tmp_path):
|
|
50
|
+
k = tmp_path / "id"
|
|
51
|
+
k.write_text("x")
|
|
52
|
+
k.chmod(0o644)
|
|
53
|
+
r = lint(f"Host a\n IdentityFile {k}\n")
|
|
54
|
+
assert any(l == "error" for _, l, _ in r)
|
|
55
|
+
k.chmod(0o600)
|
|
56
|
+
assert lint(f"Host a\n IdentityFile {k}\n") == []
|
|
57
|
+
assert any("does not exist" in m for _, _, m in lint(f"Host a\n IdentityFile {tmp_path}/nope\n"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_cli(tmp_path, capsys):
|
|
61
|
+
c = tmp_path / "config"
|
|
62
|
+
c.write_text("Host a\n ProxyJump a\n")
|
|
63
|
+
c.chmod(0o600)
|
|
64
|
+
assert main([str(c)]) == 1
|
|
65
|
+
c.write_text("Host a\n User x\n")
|
|
66
|
+
assert main([str(c)]) == 0
|
|
67
|
+
assert main([str(tmp_path / "missing")]) == 2
|
|
68
|
+
c.write_text("Host a\n User x\nHost a\n")
|
|
69
|
+
assert main([str(c)]) == 0
|
|
70
|
+
assert main(["--strict", str(c)]) == 1
|
|
71
|
+
c.chmod(0o666)
|
|
72
|
+
assert main([str(c)]) == 1
|