stablemate-saddlebag 2.0.0__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.
- saddlebag/README.md +13 -0
- saddlebag/__init__.py +7 -0
- saddlebag/browser.py +190 -0
- saddlebag/catalogue.py +95 -0
- saddlebag/cli.py +361 -0
- saddlebag/custody.py +102 -0
- saddlebag/daemon.py +88 -0
- saddlebag/keyring_store.py +167 -0
- saddlebag/models.py +73 -0
- saddlebag/preference.py +59 -0
- saddlebag/protocol.py +175 -0
- saddlebag/route.py +75 -0
- saddlebag/store.py +41 -0
- saddlebag/totp.py +44 -0
- saddlebag/vault.py +117 -0
- saddlebag/verbs.py +233 -0
- stablemate_saddlebag-2.0.0.dist-info/METADATA +299 -0
- stablemate_saddlebag-2.0.0.dist-info/RECORD +21 -0
- stablemate_saddlebag-2.0.0.dist-info/WHEEL +4 -0
- stablemate_saddlebag-2.0.0.dist-info/entry_points.txt +2 -0
- stablemate_saddlebag-2.0.0.dist-info/licenses/LICENSE +21 -0
saddlebag/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# saddlebag
|
|
2
|
+
|
|
3
|
+
The package behind the `saddlebag` command. A daemon holds the Vault token and answers verbs on a unix socket. The command line is a thin client of that socket. Each secret reaches its consumer without being returned to the caller.
|
|
4
|
+
|
|
5
|
+
## Map
|
|
6
|
+
|
|
7
|
+
- `browser.py`: the `fill` delivery path. It finds a page over the Chrome DevTools Protocol on loopback and types a value into it.
|
|
8
|
+
- `cli.py`: the argparse entry point. Every verb builds a request and prints the reply, except `serve`, which runs the daemon, and `exec`, which replaces itself with the command.
|
|
9
|
+
- `daemon.py`: the socket listener and the verb handlers. It answers one connection at a time and never logs a value.
|
|
10
|
+
- `models.py`: the secret record, tag parsing and name validation. A secret names which values exist and never holds one.
|
|
11
|
+
- `protocol.py`: the socket path, the newline-terminated JSON framing and the client `send`.
|
|
12
|
+
- `totp.py`: RFC 6238 codes computed in-process, so a seed never leaves.
|
|
13
|
+
- `vault.py`: the `SecretStore` protocol and the Vault KV v2 adapter. It is the only place a stored value is kept.
|
saddlebag/__init__.py
ADDED
saddlebag/browser.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Typing a stored secret into a live browser, over the Chrome DevTools Protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from websockets.sync.client import connect
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:9222"
|
|
18
|
+
|
|
19
|
+
TIMEOUT_SECONDS = 15.0
|
|
20
|
+
|
|
21
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrowserError(RuntimeError):
|
|
25
|
+
"""The browser could not be reached, or could not be made to accept the value."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Target:
|
|
30
|
+
"""One inspectable page in the browser."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
url: str
|
|
34
|
+
title: str
|
|
35
|
+
websocket_url: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CdpSession(Protocol):
|
|
39
|
+
"""One command channel to a page."""
|
|
40
|
+
|
|
41
|
+
def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebSocketSession:
|
|
45
|
+
"""A :class:`CdpSession` over the page's debugger WebSocket."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, websocket_url: str) -> None:
|
|
48
|
+
self._socket = connect(websocket_url, open_timeout=TIMEOUT_SECONDS, max_size=None)
|
|
49
|
+
self._next_id = 0
|
|
50
|
+
|
|
51
|
+
def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
self._next_id += 1
|
|
53
|
+
message_id = self._next_id
|
|
54
|
+
self._socket.send(json.dumps({"id": message_id, "method": method, "params": params}))
|
|
55
|
+
while True:
|
|
56
|
+
frame = json.loads(self._socket.recv(timeout=TIMEOUT_SECONDS))
|
|
57
|
+
if frame.get("id") != message_id:
|
|
58
|
+
continue
|
|
59
|
+
if "error" in frame:
|
|
60
|
+
raise BrowserError(f"{method} failed: {frame['error'].get('message', frame['error'])}")
|
|
61
|
+
return frame.get("result", {})
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._socket.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require_loopback(endpoint: str) -> None:
|
|
68
|
+
"""Refuse an endpoint that is not on this machine."""
|
|
69
|
+
host = urllib.parse.urlparse(endpoint).hostname
|
|
70
|
+
if host is None or host.lower() not in LOOPBACK_HOSTS:
|
|
71
|
+
raise BrowserError(
|
|
72
|
+
f"refusing to send a secret to non-loopback CDP endpoint {endpoint!r}. "
|
|
73
|
+
"A DevTools port grants full control of the browser and is never "
|
|
74
|
+
"authenticated; saddlebag will only talk to one on this machine."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_targets(endpoint: str) -> tuple[Target, ...]:
|
|
79
|
+
"""Every inspectable page the browser is showing."""
|
|
80
|
+
require_loopback(endpoint)
|
|
81
|
+
url = endpoint.rstrip("/") + "/json/list"
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response: # noqa: S310 - loopback-checked above
|
|
84
|
+
payload = json.loads(response.read())
|
|
85
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
86
|
+
raise BrowserError(
|
|
87
|
+
f"no browser answering at {endpoint}: {exc}. Start one with "
|
|
88
|
+
"--remote-debugging-port, e.g. "
|
|
89
|
+
"`chromium --remote-debugging-port=9222 --user-data-dir=/tmp/qa-profile`"
|
|
90
|
+
) from exc
|
|
91
|
+
targets = tuple(
|
|
92
|
+
Target(
|
|
93
|
+
id=entry.get("id", ""),
|
|
94
|
+
url=entry.get("url", ""),
|
|
95
|
+
title=entry.get("title", ""),
|
|
96
|
+
websocket_url=entry.get("webSocketDebuggerUrl", ""),
|
|
97
|
+
)
|
|
98
|
+
for entry in payload
|
|
99
|
+
if entry.get("type") == "page" and entry.get("webSocketDebuggerUrl")
|
|
100
|
+
)
|
|
101
|
+
for target in targets:
|
|
102
|
+
require_loopback(target.websocket_url)
|
|
103
|
+
return targets
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def select_target(targets: tuple[Target, ...], url_contains: str | None) -> Target:
|
|
107
|
+
"""The one page to type into."""
|
|
108
|
+
if not targets:
|
|
109
|
+
raise BrowserError("the browser has no inspectable pages open")
|
|
110
|
+
matches = (
|
|
111
|
+
targets
|
|
112
|
+
if url_contains is None
|
|
113
|
+
else tuple(t for t in targets if url_contains in t.url)
|
|
114
|
+
)
|
|
115
|
+
if not matches:
|
|
116
|
+
listing = "\n".join(f" - {t.url}" for t in targets)
|
|
117
|
+
raise BrowserError(f"no open page's URL contains {url_contains!r}. Open pages:\n{listing}")
|
|
118
|
+
if len(matches) > 1:
|
|
119
|
+
listing = "\n".join(f" - {t.url}" for t in matches)
|
|
120
|
+
raise BrowserError(
|
|
121
|
+
f"{len(matches)} pages match {url_contains!r}; narrow it so the target is "
|
|
122
|
+
f"unambiguous:\n{listing}"
|
|
123
|
+
)
|
|
124
|
+
return next(iter(matches))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_FOCUS = """
|
|
128
|
+
(() => {
|
|
129
|
+
const el = document.querySelector(%s);
|
|
130
|
+
if (!el) return { found: false };
|
|
131
|
+
el.focus();
|
|
132
|
+
if (typeof el.select === 'function') el.select();
|
|
133
|
+
return {
|
|
134
|
+
found: true,
|
|
135
|
+
editable: el.isContentEditable || ['INPUT', 'TEXTAREA'].includes(el.tagName),
|
|
136
|
+
tag: el.tagName,
|
|
137
|
+
type: el.getAttribute('type'),
|
|
138
|
+
};
|
|
139
|
+
})()
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_LENGTH = """
|
|
143
|
+
(() => {
|
|
144
|
+
const el = document.querySelector(%s);
|
|
145
|
+
if (!el) return -1;
|
|
146
|
+
return (el.isContentEditable ? el.textContent : el.value || '').length;
|
|
147
|
+
})()
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _evaluate(session: CdpSession, expression: str) -> Any:
|
|
152
|
+
result = session.send(
|
|
153
|
+
"Runtime.evaluate",
|
|
154
|
+
{"expression": expression, "returnByValue": True, "awaitPromise": True},
|
|
155
|
+
)
|
|
156
|
+
if "exceptionDetails" in result:
|
|
157
|
+
details = result["exceptionDetails"]
|
|
158
|
+
raise BrowserError(f"page script failed: {details.get('text', details)}")
|
|
159
|
+
return result.get("result", {}).get("value")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fill(session: CdpSession, selector: str, value: str) -> int:
|
|
163
|
+
"""Type ``value`` into ``selector`` on an already-open page."""
|
|
164
|
+
selector_literal = json.dumps(selector)
|
|
165
|
+
found = _evaluate(session, _FOCUS % selector_literal)
|
|
166
|
+
if not isinstance(found, dict) or not found.get("found"):
|
|
167
|
+
raise BrowserError(f"no element matches {selector!r} on that page")
|
|
168
|
+
if not found.get("editable"):
|
|
169
|
+
raise BrowserError(
|
|
170
|
+
f"{selector!r} matches a <{str(found.get('tag', '?')).lower()}>, which cannot "
|
|
171
|
+
"hold typed text; name the input itself"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
session.send("Input.insertText", {"text": value})
|
|
175
|
+
|
|
176
|
+
length = _evaluate(session, _LENGTH % selector_literal)
|
|
177
|
+
if not isinstance(length, int) or length <= 0:
|
|
178
|
+
raise BrowserError(
|
|
179
|
+
f"{selector!r} is still empty after the insert — the field is probably "
|
|
180
|
+
"read-only, or the page replaced it while it was being filled"
|
|
181
|
+
)
|
|
182
|
+
if length != len(value):
|
|
183
|
+
logger.warning(
|
|
184
|
+
"%s holds %d characters after inserting %d — the field may be masked, "
|
|
185
|
+
"truncating or reformatting",
|
|
186
|
+
selector,
|
|
187
|
+
length,
|
|
188
|
+
len(value),
|
|
189
|
+
)
|
|
190
|
+
return length
|
saddlebag/catalogue.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The keyring shelf's records, in one owner-only JSON file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import platformdirs
|
|
11
|
+
|
|
12
|
+
from saddlebag.models import Secret
|
|
13
|
+
from saddlebag.store import StoreUnavailable
|
|
14
|
+
|
|
15
|
+
VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Catalogue:
|
|
19
|
+
"""The keyring shelf's records, in one owner-only JSON file. It serialises Secret and nothing else."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: Path) -> None:
|
|
22
|
+
self.path = path
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def default(cls) -> Catalogue:
|
|
26
|
+
"""The catalogue under the stablemate state directory."""
|
|
27
|
+
return cls(Path(platformdirs.user_state_dir("stablemate")) / "saddlebag" / "catalogue.json")
|
|
28
|
+
|
|
29
|
+
def _load(self) -> dict[str, Secret]:
|
|
30
|
+
try:
|
|
31
|
+
raw = self.path.read_text(encoding="utf-8")
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
return {}
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise StoreUnavailable(f"cannot read the catalogue {self.path}: {exc.strerror or exc}") from exc
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(raw)
|
|
38
|
+
if not isinstance(payload, dict) or payload.get("version") != VERSION:
|
|
39
|
+
raise ValueError("unsupported version")
|
|
40
|
+
return {secret.name: secret for secret in map(_secret, payload["secrets"])}
|
|
41
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
42
|
+
raise StoreUnavailable(f"the catalogue {self.path} is not a version {VERSION} catalogue: fix or remove it") from exc
|
|
43
|
+
|
|
44
|
+
def _save(self, secrets: dict[str, Secret]) -> None:
|
|
45
|
+
parent = self.path.parent
|
|
46
|
+
created = not parent.exists()
|
|
47
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
if created:
|
|
49
|
+
parent.chmod(0o700)
|
|
50
|
+
payload = json.dumps({"version": VERSION, "secrets": [secrets[name].to_dict() for name in sorted(secrets)]}, indent=2)
|
|
51
|
+
temp = parent / f".{self.path.name}.{os.getpid()}.tmp"
|
|
52
|
+
descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
55
|
+
handle.write(payload + "\n")
|
|
56
|
+
os.replace(temp, self.path)
|
|
57
|
+
except BaseException:
|
|
58
|
+
temp.unlink(missing_ok=True)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
def names(self) -> list[str]:
|
|
62
|
+
"""The record names, sorted."""
|
|
63
|
+
return sorted(self._load())
|
|
64
|
+
|
|
65
|
+
def get(self, name: str) -> Secret | None:
|
|
66
|
+
"""The record called ``name``, or None."""
|
|
67
|
+
return self._load().get(name)
|
|
68
|
+
|
|
69
|
+
def put(self, secret: Secret) -> None:
|
|
70
|
+
"""Insert or replace a record, then save."""
|
|
71
|
+
secrets = self._load()
|
|
72
|
+
secrets[secret.name] = secret
|
|
73
|
+
self._save(secrets)
|
|
74
|
+
|
|
75
|
+
def drop(self, name: str) -> None:
|
|
76
|
+
"""Forget a record, then save."""
|
|
77
|
+
secrets = self._load()
|
|
78
|
+
secrets.pop(name, None)
|
|
79
|
+
self._save(secrets)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_KEYS = frozenset({"name", "username", "description", "var", "tags", "fields"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _secret(entry: Any) -> Secret:
|
|
86
|
+
if not isinstance(entry, dict) or set(entry) != _KEYS:
|
|
87
|
+
raise ValueError("unexpected record keys")
|
|
88
|
+
return Secret(
|
|
89
|
+
name=entry["name"],
|
|
90
|
+
username=entry["username"],
|
|
91
|
+
description=entry["description"],
|
|
92
|
+
var=entry["var"],
|
|
93
|
+
tags=frozenset((key, value) for key, value in entry["tags"]),
|
|
94
|
+
fields=frozenset(entry["fields"]),
|
|
95
|
+
)
|
saddlebag/cli.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""saddlebag command-line entry point: argparse front end that builds a request, routes it and prints the reply; `exec`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from saddlebag import browser, custody, daemon, preference, protocol, route
|
|
16
|
+
from saddlebag.custody import CustodyError
|
|
17
|
+
from saddlebag.keyring_store import open_door
|
|
18
|
+
from saddlebag.models import FIELDS, VALUE_FIELDS, parse_tag
|
|
19
|
+
from saddlebag.protocol import DaemonNotRunning, Reply, Request
|
|
20
|
+
from saddlebag.route import RouteError
|
|
21
|
+
from saddlebag.store import StoreUnavailable
|
|
22
|
+
from saddlebag.vault import VaultError, VaultStore
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _emit(data: object) -> None:
|
|
28
|
+
json.dump(data, sys.stdout, indent=2)
|
|
29
|
+
sys.stdout.write("\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _error(message: str, code: int) -> int:
|
|
33
|
+
print(f"saddlebag: {message}", file=sys.stderr)
|
|
34
|
+
return code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ask(verb: str, args: dict[str, object] | None = None) -> Reply | int:
|
|
38
|
+
try:
|
|
39
|
+
reply = route.choose().ask(Request(verb, args or {}))
|
|
40
|
+
except (DaemonNotRunning, StoreUnavailable, RouteError) as exc:
|
|
41
|
+
return _error(str(exc), 1)
|
|
42
|
+
except protocol.ProtocolError as exc:
|
|
43
|
+
return _error(f"the daemon sent an unreadable reply: {exc}", 1)
|
|
44
|
+
if not reply.ok:
|
|
45
|
+
return _error(reply.error or f"{verb} failed", 1)
|
|
46
|
+
return reply
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UsageError(Exception):
|
|
50
|
+
"""The command line is malformed. The message is the one line printed."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_tags(tags: list[str]) -> list[str]:
|
|
54
|
+
for tag in tags:
|
|
55
|
+
try:
|
|
56
|
+
parse_tag(tag)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise UsageError(str(exc)) from exc
|
|
59
|
+
return tags
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _table(rows: list[list[str]], header: list[str]) -> None:
|
|
63
|
+
widths = [max(len(row[i]) for row in [header, *rows]) for i in range(len(header))]
|
|
64
|
+
for row in [header, *rows]:
|
|
65
|
+
print(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pairs(raw: list[list[str]]) -> str:
|
|
69
|
+
return ",".join(f"{k}={v}" for k, v in raw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
73
|
+
try:
|
|
74
|
+
custody.refuse_environment()
|
|
75
|
+
if args.keep_token:
|
|
76
|
+
custody.keep(sys.stdin, open_door)
|
|
77
|
+
print("kept the Vault token in the OS keyring", file=sys.stderr)
|
|
78
|
+
return 0
|
|
79
|
+
addr = args.vault_addr or os.environ.get("VAULT_ADDR")
|
|
80
|
+
if not addr:
|
|
81
|
+
return _error("no Vault address: set VAULT_ADDR or pass --vault-addr", 2)
|
|
82
|
+
token = custody.find(sys.stdin, open_door, custody.filed_path())
|
|
83
|
+
except (CustodyError, StoreUnavailable) as exc:
|
|
84
|
+
return _error(str(exc), 2)
|
|
85
|
+
try:
|
|
86
|
+
store = VaultStore(addr, token.value)
|
|
87
|
+
except VaultError as exc:
|
|
88
|
+
return _error(str(exc), 1)
|
|
89
|
+
path = protocol.fit_socket_path(args.socket) if args.socket else protocol.socket_path()
|
|
90
|
+
stop = threading.Event()
|
|
91
|
+
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
|
92
|
+
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
|
93
|
+
try:
|
|
94
|
+
daemon.serve(store, socket_path=path, browser_endpoint=args.cdp, stop=stop, token_source=token.source)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
return _error(str(exc), 1)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
101
|
+
reply = _ask("list", {"tags": _check_tags(args.tag)})
|
|
102
|
+
if isinstance(reply, int):
|
|
103
|
+
return reply
|
|
104
|
+
if args.json:
|
|
105
|
+
_emit(reply.result)
|
|
106
|
+
return 0
|
|
107
|
+
rows = [
|
|
108
|
+
[s["name"], s["username"] or "", s["var"] or "", ",".join(s["fields"]), _pairs(s["tags"]), s["description"] or ""]
|
|
109
|
+
for s in reply.result["secrets"]
|
|
110
|
+
]
|
|
111
|
+
_table(rows, ["NAME", "USERNAME", "VAR", "VALUES", "TAGS", "DESCRIPTION"])
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_tags(args: argparse.Namespace) -> int:
|
|
116
|
+
reply = _ask("tags", {"tags": _check_tags(args.tag)})
|
|
117
|
+
if isinstance(reply, int):
|
|
118
|
+
return reply
|
|
119
|
+
for pair in reply.result["tags"]:
|
|
120
|
+
print(f"{pair[0]}={pair[1]}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_create(args: argparse.Namespace) -> int:
|
|
125
|
+
reply = _ask(
|
|
126
|
+
"create",
|
|
127
|
+
{"name": args.name, "username": args.username, "description": args.description, "var": args.var, "tags": _check_tags(args.tag)},
|
|
128
|
+
)
|
|
129
|
+
if isinstance(reply, int):
|
|
130
|
+
return reply
|
|
131
|
+
if args.json:
|
|
132
|
+
_emit(reply.result)
|
|
133
|
+
else:
|
|
134
|
+
print(f"created {args.name}")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
139
|
+
value = sys.stdin.read().rstrip("\r\n")
|
|
140
|
+
if not value.strip():
|
|
141
|
+
return _error("stdin gave an empty value", 2)
|
|
142
|
+
reply = _ask("set", {"name": args.name, "field": args.field, "value": value})
|
|
143
|
+
if isinstance(reply, int):
|
|
144
|
+
return reply
|
|
145
|
+
print(f"stored {reply.result['characters']} characters as {args.name} {args.field}")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_unset(args: argparse.Namespace) -> int:
|
|
150
|
+
reply = _ask("unset", {"name": args.name, "field": args.field})
|
|
151
|
+
if isinstance(reply, int):
|
|
152
|
+
return reply
|
|
153
|
+
print(f"cleared {args.field} of {args.name}")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_remove(args: argparse.Namespace) -> int:
|
|
158
|
+
reply = _ask("remove", {"name": args.name})
|
|
159
|
+
if isinstance(reply, int):
|
|
160
|
+
return reply
|
|
161
|
+
print(f"removed {args.name}")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_tag(args: argparse.Namespace) -> int:
|
|
166
|
+
reply = _ask(args.command_name, {"name": args.name, "tags": _check_tags(args.tags)})
|
|
167
|
+
if isinstance(reply, int):
|
|
168
|
+
return reply
|
|
169
|
+
print(f"{'tagged' if args.command_name == 'tag' else 'untagged'} {args.name}")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_fill(args: argparse.Namespace) -> int:
|
|
174
|
+
reply = _ask(
|
|
175
|
+
"fill",
|
|
176
|
+
{
|
|
177
|
+
"name": args.name,
|
|
178
|
+
"field": args.field,
|
|
179
|
+
"selector": args.selector,
|
|
180
|
+
"url_contains": args.url_contains,
|
|
181
|
+
"cdp": args.cdp,
|
|
182
|
+
"min_seconds": args.min_seconds,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
if isinstance(reply, int):
|
|
186
|
+
return reply
|
|
187
|
+
if args.json:
|
|
188
|
+
_emit(reply.result)
|
|
189
|
+
return 0
|
|
190
|
+
extra = f", valid for {reply.result['valid_for_seconds']}s" if "valid_for_seconds" in reply.result else ""
|
|
191
|
+
print(f"filled {reply.result['characters']} characters into {args.selector} on {reply.result['url']}{extra}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _binding(text: str) -> dict[str, object]:
|
|
196
|
+
var, sep, source = text.partition("=")
|
|
197
|
+
name, _, field = source.partition(".")
|
|
198
|
+
if not sep or not var or not name:
|
|
199
|
+
raise ValueError(f"invalid --env {text!r}: expected VAR=NAME or VAR=NAME.field")
|
|
200
|
+
return {"var": var, "name": name, "field": field or "password"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_exec(args: argparse.Namespace) -> int:
|
|
204
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
205
|
+
if not command:
|
|
206
|
+
return _error("exec needs a command after --", 2)
|
|
207
|
+
if not args.tag and not args.env:
|
|
208
|
+
return _error("exec needs at least one --tag or --env", 2)
|
|
209
|
+
try:
|
|
210
|
+
bindings = [_binding(text) for text in args.env]
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
return _error(str(exc), 2)
|
|
213
|
+
tags = _check_tags(args.tag)
|
|
214
|
+
reply = _ask("env", {"tags": tags, "bindings": bindings})
|
|
215
|
+
if isinstance(reply, int):
|
|
216
|
+
return reply
|
|
217
|
+
values = reply.result.get("values")
|
|
218
|
+
if not isinstance(values, dict):
|
|
219
|
+
return _error("the reply carried no environment", 1)
|
|
220
|
+
try:
|
|
221
|
+
os.execvpe(command[0], command, {**os.environ, **values})
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
return _error(f"cannot run {command[0]}: {exc.strerror or exc}", 127)
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
228
|
+
reply = _ask("doctor")
|
|
229
|
+
if isinstance(reply, int):
|
|
230
|
+
return reply
|
|
231
|
+
result = reply.result
|
|
232
|
+
chosen = preference.read()
|
|
233
|
+
result["preference"] = None if chosen is None else {"backend": chosen.backend, "source": chosen.source}
|
|
234
|
+
if args.json:
|
|
235
|
+
_emit(result)
|
|
236
|
+
else:
|
|
237
|
+
route = result["route"]
|
|
238
|
+
if route.startswith("socket "):
|
|
239
|
+
route = f"{route} (daemon {result['version']})"
|
|
240
|
+
print(f"backend: {result['backend']}")
|
|
241
|
+
print(f"route: {route}")
|
|
242
|
+
print(f"preference: {'none (auto)' if chosen is None else f'{chosen.backend} ({chosen.source})'}")
|
|
243
|
+
print(f"store reachable: {'yes' if result['store_reachable'] else 'no'}")
|
|
244
|
+
print(f"secrets without a stored value: {', '.join(result['secrets_without_value']) or 'none'}")
|
|
245
|
+
if result["backend"] != "vault":
|
|
246
|
+
orphans = result["orphan_items"]
|
|
247
|
+
print(f"orphan keyring items: {'not checked on this keyring' if orphans is None else ', '.join(orphans) or 'none'}")
|
|
248
|
+
if result.get("interpreter"):
|
|
249
|
+
print(f"interpreter: {result['interpreter']}")
|
|
250
|
+
for item in result["missing_items"]:
|
|
251
|
+
print(f"warning: the catalogue lists a value for {item} that the keyring no longer holds")
|
|
252
|
+
token_source = result.get("token_source")
|
|
253
|
+
if result["backend"] == "vault" and token_source is not None:
|
|
254
|
+
print(f"token source: {token_source}")
|
|
255
|
+
if token_source.startswith("file "):
|
|
256
|
+
print("warning: the Vault token is read from a file; any process of this user can read it. Prefer saddlebag serve --keep-token.")
|
|
257
|
+
if result["vault_token_file"]:
|
|
258
|
+
if result["backend"] == "vault":
|
|
259
|
+
print("warning: ~/.vault-token exists on the daemon's host; any process of this user can read it")
|
|
260
|
+
else:
|
|
261
|
+
print("warning: ~/.vault-token exists; any process of this user can read it")
|
|
262
|
+
healthy = result["store_reachable"] and not result["secrets_without_value"] and not result["missing_items"]
|
|
263
|
+
return 0 if healthy else 1
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _tag_option(parser: argparse.ArgumentParser) -> None:
|
|
267
|
+
parser.add_argument("--tag", action="append", default=[], metavar="K=V", help="match secrets carrying this tag; repeatable")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
271
|
+
p = argparse.ArgumentParser(prog="saddlebag", description="Fill secrets into browsers and commands without printing them.")
|
|
272
|
+
p.add_argument("--version", action="version", version=f"saddlebag {_pkg_version('stablemate-saddlebag')}")
|
|
273
|
+
p.add_argument("-v", "--verbose", action="store_true", help="debug logging on stderr")
|
|
274
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
275
|
+
|
|
276
|
+
s = sub.add_parser("serve", help="run the daemon; the Vault token comes from stdin, the OS keyring or a 0600 file")
|
|
277
|
+
s.add_argument("--socket", type=Path, metavar="PATH", help="socket to listen on")
|
|
278
|
+
s.add_argument("--vault-addr", metavar="URL", help="Vault address (default $VAULT_ADDR)")
|
|
279
|
+
s.add_argument("--cdp", default=browser.DEFAULT_ENDPOINT, metavar="URL", help="DevTools endpoint, loopback only")
|
|
280
|
+
s.add_argument(
|
|
281
|
+
"--keep-token",
|
|
282
|
+
action="store_true",
|
|
283
|
+
help="read a Vault token from stdin, store it in the OS keyring for later `serve` runs, and exit",
|
|
284
|
+
)
|
|
285
|
+
s.set_defaults(func=cmd_serve)
|
|
286
|
+
|
|
287
|
+
ls = sub.add_parser("list", help="list secrets and what they are for")
|
|
288
|
+
_tag_option(ls)
|
|
289
|
+
ls.add_argument("--json", action="store_true")
|
|
290
|
+
ls.set_defaults(func=cmd_list)
|
|
291
|
+
|
|
292
|
+
tg = sub.add_parser("tags", help="list the distinct tags of the matching secrets")
|
|
293
|
+
_tag_option(tg)
|
|
294
|
+
tg.set_defaults(func=cmd_tags)
|
|
295
|
+
|
|
296
|
+
sec = sub.add_parser("secret", help="create and change secrets")
|
|
297
|
+
ssub = sec.add_subparsers(dest="secret_command", required=True)
|
|
298
|
+
c = ssub.add_parser("create", help="create a secret with no value yet")
|
|
299
|
+
c.add_argument("name")
|
|
300
|
+
c.add_argument("--username")
|
|
301
|
+
c.add_argument("--description")
|
|
302
|
+
c.add_argument("--var", help="environment variable `exec` fills with the password")
|
|
303
|
+
c.add_argument("--tag", action="append", default=[], metavar="K=V")
|
|
304
|
+
c.add_argument("--json", action="store_true")
|
|
305
|
+
c.set_defaults(func=cmd_create)
|
|
306
|
+
st = ssub.add_parser("set", help="store a value read from stdin")
|
|
307
|
+
st.add_argument("name")
|
|
308
|
+
st.add_argument("--field", choices=VALUE_FIELDS, default="password")
|
|
309
|
+
st.set_defaults(func=cmd_set)
|
|
310
|
+
us = ssub.add_parser("unset", help="forget a stored value")
|
|
311
|
+
us.add_argument("name")
|
|
312
|
+
us.add_argument("--field", choices=VALUE_FIELDS, required=True)
|
|
313
|
+
us.set_defaults(func=cmd_unset)
|
|
314
|
+
rm = ssub.add_parser("remove", help="delete a secret and every version")
|
|
315
|
+
rm.add_argument("name")
|
|
316
|
+
rm.set_defaults(func=cmd_remove)
|
|
317
|
+
for verb in ("tag", "untag"):
|
|
318
|
+
t = ssub.add_parser(verb, help=f"{verb} a secret")
|
|
319
|
+
t.add_argument("name")
|
|
320
|
+
t.add_argument("tags", nargs="+", metavar="K=V")
|
|
321
|
+
t.set_defaults(func=cmd_tag, command_name=verb)
|
|
322
|
+
|
|
323
|
+
f = sub.add_parser("fill", help="type a value into a browser field over CDP")
|
|
324
|
+
f.add_argument("name")
|
|
325
|
+
f.add_argument("--field", choices=FIELDS, default="password")
|
|
326
|
+
f.add_argument("--selector", required=True, metavar="CSS")
|
|
327
|
+
f.add_argument("--url-contains", metavar="TEXT")
|
|
328
|
+
f.add_argument("--cdp", metavar="URL", help="DevTools endpoint, loopback only (default: the daemon's)")
|
|
329
|
+
f.add_argument("--min-seconds", type=int, default=5, metavar="N")
|
|
330
|
+
f.add_argument("--json", action="store_true")
|
|
331
|
+
f.set_defaults(func=cmd_fill)
|
|
332
|
+
|
|
333
|
+
e = sub.add_parser("exec", help="run a command with secrets in its environment")
|
|
334
|
+
_tag_option(e)
|
|
335
|
+
e.add_argument("--env", action="append", default=[], metavar="VAR=NAME[.field]")
|
|
336
|
+
e.add_argument("command", nargs=argparse.REMAINDER, metavar="-- CMD")
|
|
337
|
+
e.set_defaults(func=cmd_exec)
|
|
338
|
+
|
|
339
|
+
d = sub.add_parser("doctor", help="check the daemon, Vault and the stored values")
|
|
340
|
+
d.add_argument("--json", action="store_true")
|
|
341
|
+
d.set_defaults(func=cmd_doctor)
|
|
342
|
+
return p
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main(argv: list[str] | None = None) -> None:
|
|
346
|
+
args = build_parser().parse_args(argv)
|
|
347
|
+
logging.basicConfig(
|
|
348
|
+
stream=sys.stderr,
|
|
349
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
350
|
+
format="%(name)s %(levelname)s: %(message)s",
|
|
351
|
+
)
|
|
352
|
+
try:
|
|
353
|
+
raise SystemExit(args.func(args))
|
|
354
|
+
except UsageError as exc:
|
|
355
|
+
raise SystemExit(_error(str(exc), 2)) from exc
|
|
356
|
+
except BrokenPipeError:
|
|
357
|
+
raise SystemExit(0) from None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
main()
|