keywarden-ssh 0.3.0__tar.gz → 0.3.1__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.
Files changed (30) hide show
  1. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/PKG-INFO +11 -2
  2. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/README.md +10 -1
  3. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/pyproject.toml +1 -1
  4. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/__init__.py +1 -1
  5. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/cli.py +18 -0
  6. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/collect.py +28 -6
  7. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/ui.py +14 -2
  8. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/ui_page.py +16 -2
  9. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden_ssh.egg-info/PKG-INFO +11 -2
  10. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden_ssh.egg-info/SOURCES.txt +1 -0
  11. keywarden_ssh-0.3.1/tests/test_platform.py +54 -0
  12. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/LICENSE +0 -0
  13. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/setup.cfg +0 -0
  14. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/__main__.py +0 -0
  15. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/access.py +0 -0
  16. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/check.py +0 -0
  17. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/controls.py +0 -0
  18. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/evidence.py +0 -0
  19. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/inventory.py +0 -0
  20. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/parse.py +0 -0
  21. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/term.py +0 -0
  22. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/ui_lab.py +0 -0
  23. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden/waivers.py +0 -0
  24. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden_ssh.egg-info/dependency_links.txt +0 -0
  25. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden_ssh.egg-info/entry_points.txt +0 -0
  26. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/src/keywarden_ssh.egg-info/top_level.txt +0 -0
  27. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/tests/test_access.py +0 -0
  28. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/tests/test_evidence.py +0 -0
  29. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/tests/test_keywarden.py +0 -0
  30. {keywarden_ssh-0.3.0 → keywarden_ssh-0.3.1}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keywarden-ssh
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Check SSH posture around the public keys you push. Never stores them.
5
5
  Author: Michael Waxman
6
6
  License-Expression: MIT
@@ -31,6 +31,15 @@ One command checks this machine, one host, or every host in an Ansible inventory
31
31
 
32
32
  `keywarden ui` on the built-in lab hosts. Screenshots: [findings, light](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-light.webp) · [findings, dark](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-dark.webp) · [access review](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/access-review.webp).
33
33
 
34
+ ## Try it in a minute
35
+
36
+ ```sh
37
+ pip install keywarden-ssh
38
+ keywarden ui
39
+ ```
40
+
41
+ Your browser opens Keywarden. Press **Use Lab Keys**, then **Run Checks**, to see six made-up hosts with the kinds of problems real fleets have: a former employee's key on a shared admin account, a contractor key nobody uses, a root key that works from anywhere. Nothing is sent anywhere and nothing is stored. Then point it at a real host with **One host**.
42
+
34
43
  ## Install
35
44
 
36
45
  Python 3.9 or newer. No third-party packages. `--local` needs Linux or another POSIX system; the browser UI, `--host`, and `--inventory` also run on Windows using its built-in `ssh`.
@@ -42,7 +51,7 @@ python3 -m pip install keywarden-ssh
42
51
  Or straight from a tag:
43
52
 
44
53
  ```sh
45
- python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.0"
54
+ python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.1"
46
55
  ```
47
56
 
48
57
  The distribution name is `keywarden-ssh`. The `keywarden` package on PyPI is an unrelated project; do not install it expecting this tool.
@@ -10,6 +10,15 @@ One command checks this machine, one host, or every host in an Ansible inventory
10
10
 
11
11
  `keywarden ui` on the built-in lab hosts. Screenshots: [findings, light](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-light.webp) · [findings, dark](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-dark.webp) · [access review](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/access-review.webp).
12
12
 
13
+ ## Try it in a minute
14
+
15
+ ```sh
16
+ pip install keywarden-ssh
17
+ keywarden ui
18
+ ```
19
+
20
+ Your browser opens Keywarden. Press **Use Lab Keys**, then **Run Checks**, to see six made-up hosts with the kinds of problems real fleets have: a former employee's key on a shared admin account, a contractor key nobody uses, a root key that works from anywhere. Nothing is sent anywhere and nothing is stored. Then point it at a real host with **One host**.
21
+
13
22
  ## Install
14
23
 
15
24
  Python 3.9 or newer. No third-party packages. `--local` needs Linux or another POSIX system; the browser UI, `--host`, and `--inventory` also run on Windows using its built-in `ssh`.
@@ -21,7 +30,7 @@ python3 -m pip install keywarden-ssh
21
30
  Or straight from a tag:
22
31
 
23
32
  ```sh
24
- python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.0"
33
+ python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.1"
25
34
  ```
26
35
 
27
36
  The distribution name is `keywarden-ssh`. The `keywarden` package on PyPI is an unrelated project; do not install it expecting this tool.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "keywarden-ssh"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Check SSH posture around the public keys you push. Never stores them."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Check SSH posture around public keys you push. Never stores the keys."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -7,6 +7,7 @@ import csv
7
7
  import dataclasses
8
8
  import io
9
9
  import json
10
+ import os
10
11
  import sys
11
12
  from datetime import date
12
13
  from pathlib import Path
@@ -609,6 +610,13 @@ def main(argv: list[str] | None = None) -> int:
609
610
  if args.inventory:
610
611
  return _check_inventory(args)
611
612
  return _check_one(args)
613
+ except BrokenPipeError:
614
+ # Output piped into head or similar that closed early; nothing went wrong.
615
+ try:
616
+ sys.stdout = open(os.devnull, "w")
617
+ except OSError:
618
+ pass
619
+ return 0
612
620
  except PrivateKeyError:
613
621
  print(
614
622
  "Refusing to continue: a private key was in the input. Keywarden accepts public keys only and did not keep a copy.",
@@ -618,6 +626,16 @@ def main(argv: list[str] | None = None) -> int:
618
626
  except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
619
627
  print(str(exc), file=sys.stderr)
620
628
  return 2
629
+ except Exception as exc: # noqa: BLE001 - a bug should end in a message, not a traceback
630
+ if os.environ.get("KEYWARDEN_DEBUG"):
631
+ raise
632
+ print(
633
+ f"Keywarden hit an unexpected error ({type(exc).__name__}: {exc}). "
634
+ "Set KEYWARDEN_DEBUG=1 to see the details, and please report it at "
635
+ "https://github.com/generalwax2018/keywarden/issues",
636
+ file=sys.stderr,
637
+ )
638
+ return 3
621
639
 
622
640
 
623
641
  if __name__ == "__main__":
@@ -336,6 +336,18 @@ echo END
336
336
  """
337
337
 
338
338
  FINGERPRINT_TEXT = re.compile(r"SHA256:[A-Za-z0-9+/]+")
339
+ LOCAL_SUPPORTED = os.name == "posix" and hasattr(os, "geteuid")
340
+ LOCAL_UNSUPPORTED_MESSAGE = (
341
+ "--local reads this machine's sshd and user accounts, which only exist on Linux and other POSIX systems. "
342
+ "Run Keywarden on the server itself, or from here use --host user@server, --inventory hosts.ini, "
343
+ "or keywarden ui and its lab hosts."
344
+ )
345
+
346
+
347
+ def _euid() -> int:
348
+ return os.geteuid() if hasattr(os, "geteuid") else -1
349
+
350
+
339
351
  _DESTINATION = re.compile(r"^[A-Za-z0-9._:-]+@[A-Za-z0-9._:-]+$|^[A-Za-z0-9._:-]+$")
340
352
  _USERNAME = re.compile(r"^[a-z_][-a-z0-9_.]*\$?$", re.IGNORECASE)
341
353
 
@@ -446,7 +458,7 @@ def _run(command: list[str]) -> subprocess.CompletedProcess | None:
446
458
 
447
459
  def _sshd_output(binary: str, extra: list[str]) -> str | None:
448
460
  attempts = [[binary, "-T", *extra]]
449
- if os.geteuid() != 0 and shutil.which("sudo"):
461
+ if _euid() != 0 and shutil.which("sudo"):
450
462
  attempts.append(["sudo", "-n", binary, "-T", *extra])
451
463
  for command in attempts:
452
464
  completed = _run(command)
@@ -619,6 +631,8 @@ def _run_long(command: list[str]) -> subprocess.CompletedProcess | None:
619
631
 
620
632
 
621
633
  def collect_local(extra_users: list[str] | None = None) -> HostObservation:
634
+ if not LOCAL_SUPPORTED:
635
+ raise ValueError(LOCAL_UNSUPPORTED_MESSAGE)
622
636
  records, passwd_state = _passwd_records()
623
637
  accounts = []
624
638
  for user in _ordered_users(extra_users or [], records):
@@ -652,7 +666,7 @@ def collect_local(extra_users: list[str] | None = None) -> HostObservation:
652
666
  sshd_state=sshd_state,
653
667
  match_blocks=match,
654
668
  sshd_root=root,
655
- privilege="root" if os.geteuid() == 0 else "none",
669
+ privilege="root" if _euid() == 0 else "none",
656
670
  now=now,
657
671
  log_source=log_source,
658
672
  log_since=log_since,
@@ -894,6 +908,12 @@ def _parse_account(lines: list[str], index: int) -> tuple[Account, int]:
894
908
  return account, index
895
909
 
896
910
 
911
+ def _text(data) -> str:
912
+ if isinstance(data, bytes):
913
+ return data.decode("utf-8", errors="replace")
914
+ return data or ""
915
+
916
+
897
917
  def collect_remote(
898
918
  destination: str,
899
919
  extra_users: list[str] | None = None,
@@ -933,11 +953,12 @@ def collect_remote(
933
953
  options.extend(["-i", os.path.expanduser(identity_file), "-o", "IdentitiesOnly=yes"])
934
954
  command = ["ssh", *options, "--", destination, "sh", "-s", "--", *users]
935
955
  try:
956
+ # Bytes, not text: in text mode Windows would turn every newline in the
957
+ # script into CRLF on the way to the remote sh, and the script would fail.
936
958
  completed = subprocess.run(
937
959
  command,
938
- input=REMOTE_SCRIPT,
960
+ input=REMOTE_SCRIPT.encode("utf-8"),
939
961
  capture_output=True,
940
- text=True,
941
962
  timeout=60,
942
963
  check=False,
943
964
  )
@@ -945,14 +966,15 @@ def collect_remote(
945
966
  raise RuntimeError("ssh timed out before a check could finish") from exc
946
967
  except OSError as exc:
947
968
  raise RuntimeError("ssh is not available on this machine") from exc
969
+ stderr = _text(completed.stderr)
948
970
  if completed.returncode != 0:
949
971
  tail = ""
950
- for line in reversed((completed.stderr or "").splitlines()):
972
+ for line in reversed(stderr.splitlines()):
951
973
  if line.strip() and "PRIVATE KEY" not in line and "ssh-" not in line:
952
974
  tail = line.strip()[:180]
953
975
  break
954
976
  raise RuntimeError(tail or f"ssh exited {completed.returncode}")
955
- output = completed.stdout
977
+ output = _text(completed.stdout)
956
978
  try:
957
979
  return parse_protocol(output, address=destination)
958
980
  finally:
@@ -32,7 +32,15 @@ from keywarden.cli import (
32
32
  csv_text,
33
33
  prior_from_document,
34
34
  )
35
- from keywarden.collect import REMOTE_SCRIPT, ProtocolError, collect_local, collect_remote, parse_protocol
35
+ from keywarden.collect import (
36
+ LOCAL_SUPPORTED,
37
+ LOCAL_UNSUPPORTED_MESSAGE,
38
+ REMOTE_SCRIPT,
39
+ ProtocolError,
40
+ collect_local,
41
+ collect_remote,
42
+ parse_protocol,
43
+ )
36
44
  from keywarden.parse import KeyRef, PrivateKeyError, kind_of, parse_public_keys
37
45
  from keywarden.ui_lab import LAB_PUSH, lab_hosts
38
46
  from keywarden.ui_page import render_page
@@ -99,6 +107,8 @@ def _observations(body: dict):
99
107
  yield observation.name, observation, role, prior
100
108
  return
101
109
  if target == "local":
110
+ if not LOCAL_SUPPORTED:
111
+ raise BadRequest(LOCAL_UNSUPPORTED_MESSAGE.replace("--local", "This machine").replace(" or keywarden ui and its lab hosts", " or the lab hosts"))
102
112
  observation = collect_local(args.account)
103
113
  yield observation.name, observation, "This machine", None
104
114
  return
@@ -276,7 +286,7 @@ class Handler(BaseHTTPRequestHandler):
276
286
  self._headers(403, "text/plain; charset=utf-8", len(body))
277
287
  return self.wfile.write(body)
278
288
  nonce = secrets.token_urlsafe(16)
279
- page = render_page(nonce, self.server.token, __version__).encode("utf-8")
289
+ page = render_page(nonce, self.server.token, __version__, LOCAL_SUPPORTED).encode("utf-8")
280
290
  self._headers(200, "text/html; charset=utf-8", len(page), nonce)
281
291
  return self.wfile.write(page)
282
292
  return self._send_json(404, {"error": "not found"})
@@ -317,6 +327,8 @@ class Handler(BaseHTTPRequestHandler):
317
327
  return self._send_json(400, {"error": "A private key was in the input. Keywarden takes public keys only and kept nothing."})
318
328
  except (BadRequest, ValueError, OSError, RuntimeError, json.JSONDecodeError) as exc:
319
329
  return self._send_json(400, {"error": str(exc)[:400]})
330
+ except Exception as exc: # noqa: BLE001 - answer the page instead of dropping the connection
331
+ return self._send_json(500, {"error": f"Keywarden hit an unexpected error ({type(exc).__name__}). Please report it on GitHub."})
320
332
  finally:
321
333
  body.clear()
322
334
 
@@ -119,6 +119,7 @@ textarea:focus, input[type=text]:focus, input[type=number]:focus { outline: none
119
119
  .option .text { display: grid; gap: 1px; min-width: 0; }
120
120
  .option small { color: var(--secondary); font-size: 12px; }
121
121
  .option .sub { grid-column: 1 / -1; margin-top: 6px; }
122
+ .option.disabled { opacity: .5; cursor: not-allowed; }
122
123
  .option input[type=radio] { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; margin: 0; border-radius: 50%; border: 1.5px solid var(--separator-strong); background: var(--glass-strong); }
123
124
  .option input[type=radio]:checked { border: 5px solid var(--blue); background: #fff; }
124
125
  .option input[type=checkbox] { appearance: none; -webkit-appearance: none; width: 36px; height: 22px; border-radius: 11px; background: var(--fill-strong); position: relative; margin: 0; cursor: pointer; transition: background .2s; flex: none; box-shadow: inset 0 1px 2px rgba(0,0,0,.12); }
@@ -338,6 +339,7 @@ td.fp, td.mono { font-family: var(--mono); font-size: 12px; }
338
339
  <script nonce="__NONCE__">
339
340
  "use strict";
340
341
  const TOKEN = "__TOKEN__";
342
+ const LOCAL_OK = __LOCAL_OK__;
341
343
  history.replaceState(null, "", "/");
342
344
 
343
345
  const $ = (id) => document.getElementById(id);
@@ -654,6 +656,13 @@ $("prior").addEventListener("change", async (event) => {
654
656
  });
655
657
  for (const tab of document.querySelectorAll('[role="tab"]')) tab.addEventListener("click", () => showView(tab.dataset.view));
656
658
  for (const id of ["host", "inventory"]) $(id).addEventListener("focus", () => { $(id === "host" ? "t-host" : "t-inv").checked = true; });
659
+ if (!LOCAL_OK) {
660
+ // sshd and the account database live on the server; a Windows desktop has neither.
661
+ $("t-local").disabled = true;
662
+ const row = $("t-local").closest(".option");
663
+ row.classList.add("disabled");
664
+ row.querySelector("small").textContent = "Not on Windows. Run Keywarden on the Linux server, or check it as One host.";
665
+ }
657
666
  renderPush(); renderAll([]);
658
667
  </script>
659
668
  </body>
@@ -661,5 +670,10 @@ renderPush(); renderAll([]);
661
670
  """
662
671
 
663
672
 
664
- def render_page(nonce: str, token: str, version: str) -> str:
665
- return PAGE.replace("__NONCE__", nonce).replace("__TOKEN__", token).replace("__VERSION__", version)
673
+ def render_page(nonce: str, token: str, version: str, local_ok: bool = True) -> str:
674
+ return (
675
+ PAGE.replace("__NONCE__", nonce)
676
+ .replace("__TOKEN__", token)
677
+ .replace("__VERSION__", version)
678
+ .replace("__LOCAL_OK__", "true" if local_ok else "false")
679
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keywarden-ssh
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Check SSH posture around the public keys you push. Never stores them.
5
5
  Author: Michael Waxman
6
6
  License-Expression: MIT
@@ -31,6 +31,15 @@ One command checks this machine, one host, or every host in an Ansible inventory
31
31
 
32
32
  `keywarden ui` on the built-in lab hosts. Screenshots: [findings, light](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-light.webp) · [findings, dark](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-dark.webp) · [access review](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/access-review.webp).
33
33
 
34
+ ## Try it in a minute
35
+
36
+ ```sh
37
+ pip install keywarden-ssh
38
+ keywarden ui
39
+ ```
40
+
41
+ Your browser opens Keywarden. Press **Use Lab Keys**, then **Run Checks**, to see six made-up hosts with the kinds of problems real fleets have: a former employee's key on a shared admin account, a contractor key nobody uses, a root key that works from anywhere. Nothing is sent anywhere and nothing is stored. Then point it at a real host with **One host**.
42
+
34
43
  ## Install
35
44
 
36
45
  Python 3.9 or newer. No third-party packages. `--local` needs Linux or another POSIX system; the browser UI, `--host`, and `--inventory` also run on Windows using its built-in `ssh`.
@@ -42,7 +51,7 @@ python3 -m pip install keywarden-ssh
42
51
  Or straight from a tag:
43
52
 
44
53
  ```sh
45
- python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.0"
54
+ python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.1"
46
55
  ```
47
56
 
48
57
  The distribution name is `keywarden-ssh`. The `keywarden` package on PyPI is an unrelated project; do not install it expecting this tool.
@@ -24,4 +24,5 @@ src/keywarden_ssh.egg-info/top_level.txt
24
24
  tests/test_access.py
25
25
  tests/test_evidence.py
26
26
  tests/test_keywarden.py
27
+ tests/test_platform.py
27
28
  tests/test_ui.py
@@ -0,0 +1,54 @@
1
+ import subprocess
2
+ import sys
3
+ import unittest
4
+ from contextlib import redirect_stderr, redirect_stdout
5
+ from io import StringIO
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(ROOT / "src"))
11
+
12
+ from keywarden import collect # noqa: E402
13
+ from keywarden.cli import main # noqa: E402
14
+ from keywarden.ui_page import render_page # noqa: E402
15
+
16
+
17
+ class WindowsTests(unittest.TestCase):
18
+ def test_local_check_explains_itself_off_posix(self):
19
+ with patch.object(collect, "LOCAL_SUPPORTED", False):
20
+ with self.assertRaises(ValueError) as caught:
21
+ collect.collect_local()
22
+ self.assertIn("--host", str(caught.exception))
23
+ err = StringIO()
24
+ with redirect_stderr(err), redirect_stdout(StringIO()):
25
+ code = main(["check", "--local"])
26
+ self.assertEqual(code, 2)
27
+ self.assertIn("Linux", err.getvalue())
28
+ self.assertNotIn("Traceback", err.getvalue())
29
+
30
+ def test_ui_disables_this_machine_off_posix(self):
31
+ self.assertIn("const LOCAL_OK = false;", render_page("n", "t", "0", False))
32
+ self.assertIn("const LOCAL_OK = true;", render_page("n", "t", "0", True))
33
+
34
+ def test_script_is_sent_as_bytes_so_newlines_survive_windows(self):
35
+ with patch("keywarden.collect.subprocess.run") as run:
36
+ run.return_value = subprocess.CompletedProcess([], 255, b"", b"ssh: connect to host x port 22: refused\r\n")
37
+ with self.assertRaises(RuntimeError) as caught:
38
+ collect.collect_remote("deploy@x")
39
+ sent = run.call_args.kwargs["input"]
40
+ self.assertIsInstance(sent, bytes)
41
+ self.assertNotIn(b"\r\n", sent)
42
+ self.assertNotIn("text", run.call_args.kwargs)
43
+ self.assertIn("refused", str(caught.exception))
44
+
45
+ def test_unexpected_errors_end_in_a_message(self):
46
+ err = StringIO()
47
+ with patch("keywarden.cli._check_one", side_effect=KeyError("boom")), redirect_stderr(err):
48
+ code = main(["check", "--local"])
49
+ self.assertEqual(code, 3)
50
+ self.assertIn("unexpected error", err.getvalue())
51
+
52
+
53
+ if __name__ == "__main__":
54
+ unittest.main()
File without changes
File without changes