secretops 0.1.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.
secretops/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from secretops!")
@@ -0,0 +1,354 @@
1
+ """`secretops add`: the human client for interactive secret entry.
2
+
3
+ The client holds no vault credentials. It calls the broker's human endpoints
4
+ through the human's own broker access (SSH or sudo), shows the broker's
5
+ validated plan, reads the value from the terminal, and sends it once, in
6
+ the body of the submission request on the endpoint's stdin.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ from secretops.human_entry import (
20
+ PROTOCOL_VERSION,
21
+ REVIEW_OUTCOMES,
22
+ SUBMIT_OUTCOMES,
23
+ InvalidValueError,
24
+ validate_entered_value,
25
+ )
26
+ from secretops.terminal_input import (
27
+ HiddenTerminal,
28
+ InputCancelled,
29
+ InputRejected,
30
+ TerminalUnavailable,
31
+ )
32
+
33
+ CLIENT_CONFIG_ENV = "SECRETOPS_CLIENT_CONFIG"
34
+ DEFAULT_BROKER_COMMAND = "/usr/local/bin/secretops-broker"
35
+ ENDPOINT_TIMEOUT_SECONDS = 300
36
+
37
+ EXIT_CREATED = 0
38
+ EXIT_NOT_WRITTEN = 1
39
+ EXIT_CANCELLED = 2
40
+ EXIT_UNKNOWN = 3
41
+
42
+ SAFE_TOKEN = re.compile(r"[A-Za-z0-9_.@:+-]+")
43
+ SAFE_PATH = re.compile(r"/[A-Za-z0-9_./-]*")
44
+ PLAN_FIELDS = (
45
+ "plan_id",
46
+ "secret_name",
47
+ "env",
48
+ "create_mode",
49
+ "value_source",
50
+ "vault_backend",
51
+ "vault_domain",
52
+ "vault_project_id",
53
+ "vault_path",
54
+ )
55
+ SETUP_HINT = (
56
+ "configure human broker access in ~/.config/secretops/client.yaml "
57
+ "(see docs/BROKER_HOST.md, 'Human secret entry')"
58
+ )
59
+
60
+
61
+ class ClientError(Exception):
62
+ """A problem before anything was sent. Safe to print."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class BrokerAccess:
67
+ name: str
68
+ argv_prefix: tuple[str, ...]
69
+
70
+ def argv(self, *args: str) -> list[str]:
71
+ return [*self.argv_prefix, *args]
72
+
73
+
74
+ def client_config_path() -> Path:
75
+ configured = os.environ.get(CLIENT_CONFIG_ENV)
76
+ if configured:
77
+ return Path(configured)
78
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
79
+ return Path(base) / "secretops" / "client.yaml"
80
+
81
+
82
+ def _broker_command(entry: dict) -> str:
83
+ command = entry.get("broker_command", DEFAULT_BROKER_COMMAND)
84
+ if not isinstance(command, str) or not SAFE_PATH.fullmatch(command):
85
+ raise ClientError("broker_command must be an absolute path without spaces")
86
+ return command
87
+
88
+
89
+ def _access_from_entry(name: str, entry: Any) -> BrokerAccess:
90
+ if not isinstance(entry, dict):
91
+ raise ClientError(f"broker {name} is not configured correctly")
92
+ if "command" in entry and ("ssh" in entry or "sudo_user" in entry):
93
+ raise ClientError(f"broker {name}: command cannot be combined with ssh or sudo_user")
94
+ if not any(kind in entry for kind in ("ssh", "sudo_user", "command")):
95
+ raise ClientError(f"broker {name} needs ssh, sudo_user, or command")
96
+ if "sudo_user" in entry:
97
+ user = entry["sudo_user"]
98
+ if not isinstance(user, str) or not SAFE_TOKEN.fullmatch(user) or user.startswith("-"):
99
+ raise ClientError(f"broker {name}: sudo_user is not valid")
100
+ if "ssh" in entry:
101
+ destination = entry["ssh"]
102
+ if not isinstance(destination, str) or not SAFE_TOKEN.fullmatch(destination) or destination.startswith("-"):
103
+ raise ClientError(f"broker {name}: ssh must be a host or user@host")
104
+ remote = (_broker_command(entry),)
105
+ if "sudo_user" in entry:
106
+ # Personal account on the broker host, then sudo to the broker
107
+ # user. Without a remote PTY sudo cannot ask for a password, so
108
+ # -n fails instead of prompting; a narrow sudoers rule is needed.
109
+ remote = ("sudo", "-n", "-u", entry["sudo_user"], "--", *remote)
110
+ # No remote PTY and no forwarding; host-key checking stays as configured.
111
+ prefix = (
112
+ "ssh", "-T",
113
+ "-o", "RequestTTY=no",
114
+ "-o", "ForwardAgent=no",
115
+ "-o", "ForwardX11=no",
116
+ "-o", "ClearAllForwardings=yes",
117
+ destination,
118
+ *remote,
119
+ )
120
+ elif "sudo_user" in entry:
121
+ prefix = ("sudo", "-u", entry["sudo_user"], "--", _broker_command(entry))
122
+ else:
123
+ command = entry["command"]
124
+ if not isinstance(command, list) or not command or not all(
125
+ isinstance(part, str) and part for part in command
126
+ ):
127
+ raise ClientError(f"broker {name}: command must be a list of arguments")
128
+ prefix = tuple(command)
129
+ return BrokerAccess(name=name, argv_prefix=prefix)
130
+
131
+
132
+ def load_broker_access(selected: str | None) -> BrokerAccess:
133
+ path = client_config_path()
134
+ try:
135
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
136
+ except FileNotFoundError as error:
137
+ raise ClientError(f"no broker is configured; {SETUP_HINT}") from error
138
+ except (OSError, yaml.YAMLError) as error:
139
+ raise ClientError(f"cannot read {path}") from error
140
+ brokers = data.get("brokers") if isinstance(data, dict) else None
141
+ if not isinstance(brokers, dict) or not brokers:
142
+ raise ClientError(f"no broker is configured; {SETUP_HINT}")
143
+ name = selected or data.get("default_broker")
144
+ if name is None:
145
+ if len(brokers) != 1:
146
+ raise ClientError(
147
+ "several brokers are configured; choose one with --broker: "
148
+ + ", ".join(sorted(str(key) for key in brokers))
149
+ )
150
+ name = next(iter(brokers))
151
+ if name not in brokers:
152
+ raise ClientError(f"broker {name} is not configured")
153
+ return _access_from_entry(str(name), brokers[name])
154
+
155
+
156
+ def safe_text(value: Any) -> str:
157
+ """Render broker text without control characters that could alter the terminal."""
158
+ return "".join(
159
+ character if character.isprintable() else "?" for character in str(value)
160
+ )
161
+
162
+
163
+ def _parse_response(stdout: bytes) -> dict[str, Any]:
164
+ text = stdout.decode("utf-8")
165
+ if not text.endswith("\n") or "\n" in text[:-1]:
166
+ raise ValueError("response is not one line")
167
+ data = json.loads(text)
168
+ if not isinstance(data, dict) or data.get("protocol") != PROTOCOL_VERSION:
169
+ raise ValueError("unsupported response")
170
+ if not isinstance(data.get("outcome"), str) or not isinstance(data.get("message"), str):
171
+ raise ValueError("unexpected response")
172
+ return data
173
+
174
+
175
+ def parse_review_response(stdout: bytes) -> dict[str, Any]:
176
+ data = _parse_response(stdout)
177
+ if set(data) != {"protocol", "outcome", "message", "plan", "digest", "atomic_create"}:
178
+ raise ValueError("unexpected review response")
179
+ if data["outcome"] not in REVIEW_OUTCOMES:
180
+ raise ValueError("unexpected review outcome")
181
+ plan = data["plan"]
182
+ if plan is not None and (
183
+ not isinstance(plan, dict)
184
+ or set(plan) != set(PLAN_FIELDS)
185
+ or not all(isinstance(value, str) for value in plan.values())
186
+ ):
187
+ raise ValueError("unexpected plan metadata")
188
+ if data["outcome"] == "ready" and (
189
+ plan is None
190
+ or not isinstance(data["digest"], str)
191
+ or not isinstance(data["atomic_create"], bool)
192
+ ):
193
+ raise ValueError("incomplete review")
194
+ return data
195
+
196
+
197
+ def parse_submit_response(stdout: bytes) -> dict[str, Any]:
198
+ data = _parse_response(stdout)
199
+ if set(data) != {"protocol", "outcome", "message", "plan_id"}:
200
+ raise ValueError("unexpected submit response")
201
+ if data["outcome"] not in SUBMIT_OUTCOMES:
202
+ raise ValueError("unexpected submit outcome")
203
+ return data
204
+
205
+
206
+ def call_review(access: BrokerAccess, args: list[str]) -> dict[str, Any]:
207
+ """Call prepare-add or review-add. Nothing secret is sent."""
208
+ try:
209
+ result = subprocess.run(
210
+ access.argv(*args),
211
+ stdin=subprocess.DEVNULL,
212
+ stdout=subprocess.PIPE,
213
+ timeout=ENDPOINT_TIMEOUT_SECONDS,
214
+ check=False,
215
+ )
216
+ except (OSError, subprocess.TimeoutExpired) as error:
217
+ raise ClientError(f"cannot reach broker {access.name}") from error
218
+ try:
219
+ return parse_review_response(result.stdout)
220
+ except (UnicodeDecodeError, ValueError) as error:
221
+ raise ClientError(
222
+ f"broker {access.name} did not return a valid review "
223
+ f"(exit status {result.returncode}); check the human broker access"
224
+ ) from error
225
+
226
+
227
+ def call_submit(access: BrokerAccess, request: bytes) -> dict[str, Any] | None:
228
+ """Send the submission once. None means the outcome is unknown."""
229
+ try:
230
+ result = subprocess.run(
231
+ access.argv("submit-add"),
232
+ input=request,
233
+ stdout=subprocess.PIPE,
234
+ timeout=ENDPOINT_TIMEOUT_SECONDS,
235
+ check=False,
236
+ )
237
+ except (OSError, subprocess.TimeoutExpired):
238
+ return None
239
+ try:
240
+ return parse_submit_response(result.stdout)
241
+ except (UnicodeDecodeError, ValueError):
242
+ return None
243
+
244
+
245
+ def operation_text(atomic_create: bool) -> str:
246
+ if atomic_create:
247
+ return "create only; an existing secret is never replaced"
248
+ return "create only; refused if present at the final check"
249
+
250
+
251
+ def summary_lines(access: BrokerAccess, review: dict[str, Any]) -> list[str]:
252
+ plan = {key: safe_text(value) for key, value in review["plan"].items()}
253
+ return [
254
+ f"Broker: {safe_text(access.name)}",
255
+ f"Vault: {plan['vault_backend']} / {plan['vault_project_id']} / {plan['env']} / {plan['vault_path']}",
256
+ f"Server: {plan['vault_domain']}",
257
+ f"Secret: {plan['secret_name']}",
258
+ f"Operation: {operation_text(review['atomic_create'])}",
259
+ f"Plan: {plan['plan_id']}",
260
+ ]
261
+
262
+
263
+ def unknown_lines(plan_id: str | None) -> list[str]:
264
+ lines = ["Write outcome is unknown. Do not submit again yet."]
265
+ if plan_id:
266
+ lines.append(
267
+ f"Check broker status for plan {safe_text(plan_id)}; "
268
+ "if unresolved, ask the operator to verify it."
269
+ )
270
+ return lines
271
+
272
+
273
+ def run_add(echo, *, access: BrokerAccess, review_args: list[str],
274
+ terminal_factory=HiddenTerminal) -> int:
275
+ """Review, read the value, confirm, submit. Returns the exit code.
276
+
277
+ `echo` prints one line of safe text to the user.
278
+ """
279
+ review = call_review(access, review_args)
280
+ outcome = review["outcome"]
281
+ if outcome == "ready":
282
+ for line in summary_lines(access, review):
283
+ echo(line)
284
+ if outcome == "exists":
285
+ echo("Secret already exists. Nothing was written.")
286
+ return EXIT_NOT_WRITTEN
287
+ if outcome != "ready":
288
+ echo(f"Not saved: {safe_text(review['message'])}")
289
+ return EXIT_NOT_WRITTEN
290
+ plan_id = review["plan"]["plan_id"]
291
+ echo("")
292
+ echo("Checks passed. The secret is currently absent.")
293
+
294
+ try:
295
+ with terminal_factory() as terminal:
296
+ try:
297
+ value = terminal.read_hidden("Value (hidden): ")
298
+ validate_entered_value(value)
299
+ except (InputRejected, InvalidValueError) as error:
300
+ terminal.print(f"Not saved: {error}")
301
+ terminal.print(f"Nothing was sent. The plan {plan_id} can be used again.")
302
+ return EXIT_NOT_WRITTEN
303
+ for line in summary_lines(access, review)[3:5]:
304
+ terminal.print(line)
305
+ if not terminal.confirm("Save? [y/N]: "):
306
+ terminal.print("Cancelled. Nothing was sent.")
307
+ return EXIT_CANCELLED
308
+ except InputCancelled:
309
+ echo("Cancelled. Nothing was sent.")
310
+ return EXIT_CANCELLED
311
+ except TerminalUnavailable as error:
312
+ echo(f"Not saved: {error}. Nothing was sent.")
313
+ return EXIT_NOT_WRITTEN
314
+ except KeyboardInterrupt:
315
+ echo("Cancelled. Nothing was sent.")
316
+ return EXIT_CANCELLED
317
+
318
+ request = (
319
+ json.dumps(
320
+ {
321
+ "protocol": PROTOCOL_VERSION,
322
+ "plan_id": plan_id,
323
+ "digest": review["digest"],
324
+ "value": value,
325
+ },
326
+ ensure_ascii=False,
327
+ separators=(",", ":"),
328
+ )
329
+ + "\n"
330
+ ).encode("utf-8")
331
+ del value
332
+ try:
333
+ response = call_submit(access, request)
334
+ except KeyboardInterrupt:
335
+ response = None
336
+ del request
337
+ if response is None:
338
+ for line in unknown_lines(plan_id):
339
+ echo(line)
340
+ return EXIT_UNKNOWN
341
+ result = response["outcome"]
342
+ if result == "created":
343
+ echo("")
344
+ echo("Saved and verified.")
345
+ return EXIT_CREATED
346
+ if result == "exists":
347
+ echo("Secret already exists. Nothing was written by this invocation.")
348
+ return EXIT_NOT_WRITTEN
349
+ if result == "unknown":
350
+ for line in unknown_lines(plan_id):
351
+ echo(line)
352
+ return EXIT_UNKNOWN
353
+ echo(f"Not saved: {safe_text(response['message'])}")
354
+ return EXIT_NOT_WRITTEN
secretops/audit.py ADDED
@@ -0,0 +1,42 @@
1
+ import json
2
+ import os
3
+ from typing import Any
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ DEFAULT_AUDIT_PATH = Path(".audit/secretops.jsonl")
8
+ AUDIT_PATH_ENV = "SECRETOPS_AUDIT_PATH"
9
+
10
+
11
+ def audit_path() -> Path:
12
+ return Path(os.environ.get(AUDIT_PATH_ENV, DEFAULT_AUDIT_PATH))
13
+
14
+
15
+ def write_audit_event(
16
+ *,
17
+ action: str,
18
+ secret_name: str,
19
+ env: str,
20
+ status: str,
21
+ plan_id: str | None = None,
22
+ details: dict[str, Any] | None = None,
23
+ ) -> None:
24
+ """Append one audit event. `details` may carry secret names and metadata
25
+ only, never values."""
26
+ event = {
27
+ "timestamp": datetime.now(timezone.utc).isoformat(),
28
+ "action": action,
29
+ "secret_name": secret_name,
30
+ "env": env,
31
+ "status": status,
32
+ "secret_value_logged": False,
33
+ }
34
+ if plan_id is not None:
35
+ event["plan_id"] = plan_id
36
+ if details:
37
+ event.update(details)
38
+
39
+ path = audit_path()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with path.open("a", encoding="utf-8") as audit_file:
42
+ audit_file.write(json.dumps(event, separators=(",", ":")) + "\n")
@@ -0,0 +1,294 @@
1
+ """Human-only helper that writes the broker's broker.env.
2
+
3
+ Run as root from the human's own terminal (installed as
4
+ /usr/local/sbin/secretops-configure). The broker wrapper sources broker.env
5
+ as shell, so this module never sources it: it reads only lines in the exact
6
+ form it writes itself, KEY=<shlex.quote(value)>, for known keys, and writes
7
+ every value with shlex.quote. Errors name a line number, never its content.
8
+
9
+ secretops-configure # destination settings and token
10
+ secretops-configure --token-only # replace only the token
11
+
12
+ The installer uses --init once to write the initial file without a token.
13
+ No provider is called and no credential is printed.
14
+ """
15
+
16
+ import argparse
17
+ import getpass
18
+ import ipaddress
19
+ import os
20
+ import pwd
21
+ import re
22
+ import shlex
23
+ import stat
24
+ import sys
25
+ import tempfile
26
+ import warnings
27
+ from collections.abc import Callable
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ BROKER_ENV = Path("/opt/secretops/broker.env")
32
+ BROKER_USER = "secretops-broker"
33
+ TOKEN_KEY = "INFISICAL_TOKEN"
34
+ # Written in this order; unknown keys are refused.
35
+ KNOWN_KEYS = (
36
+ "SECRETOPS_BIN",
37
+ "INFISICAL_DOMAIN",
38
+ TOKEN_KEY,
39
+ "SECRETOPS_INFISICAL_PROJECT_ID",
40
+ "SECRETOPS_INFISICAL_PATH",
41
+ "SECRETOPS_INFISICAL_BIN",
42
+ "SECRETOPS_GH_BIN",
43
+ "SECRETOPS_PROVIDER_TIMEOUT_SECONDS",
44
+ "SECRETOPS_PLAN_DIR",
45
+ "SECRETOPS_AUDIT_PATH",
46
+ "SECRETOPS_POLICY_PATH",
47
+ )
48
+ # Fixed by the installed layout; --init writes them.
49
+ LAYOUT_DEFAULTS = {
50
+ "SECRETOPS_BIN": "/opt/secretops/bin/secretops",
51
+ "SECRETOPS_INFISICAL_PATH": "/",
52
+ "SECRETOPS_PLAN_DIR": "/opt/secretops/plans",
53
+ "SECRETOPS_AUDIT_PATH": "/opt/secretops/audit/secretops.jsonl",
54
+ "SECRETOPS_POLICY_PATH": "/opt/secretops/policy.yaml",
55
+ }
56
+ LINE = re.compile(r"(?P<key>[A-Z][A-Z0-9_]*)=(?P<raw>.*)")
57
+ PROJECT_ID = re.compile(r"[A-Za-z0-9_-]+")
58
+ VAULT_PATH = re.compile(r"/[A-Za-z0-9_./-]*")
59
+
60
+
61
+ class ConfigError(Exception):
62
+ """Safe to print: fixed text, line numbers, and key names only."""
63
+
64
+
65
+ def parse(text: str) -> dict[str, str]:
66
+ values: dict[str, str] = {}
67
+ for number, line in enumerate(text.splitlines(), start=1):
68
+ if not line.strip() or line.startswith("#"):
69
+ continue
70
+ match = LINE.fullmatch(line)
71
+ if not match or match["key"] not in KNOWN_KEYS:
72
+ raise ConfigError(f"broker.env line {number}: unsupported line")
73
+ if match["key"] in values:
74
+ raise ConfigError(f"broker.env line {number}: duplicate {match['key']}")
75
+ try:
76
+ parts = shlex.split(match["raw"])
77
+ except ValueError:
78
+ parts = None
79
+ # Only the canonical form this module writes is accepted, so a value
80
+ # read here means exactly what the shell will see when sourcing it.
81
+ if parts is None or len(parts) > 1:
82
+ raise ConfigError(f"broker.env line {number}: unsupported value syntax")
83
+ value = parts[0] if parts else ""
84
+ if shlex.quote(value) != match["raw"]:
85
+ raise ConfigError(f"broker.env line {number}: unsupported value syntax")
86
+ values[match["key"]] = value
87
+ return values
88
+
89
+
90
+ def serialize(values: dict[str, str]) -> str:
91
+ lines = []
92
+ for key in KNOWN_KEYS:
93
+ if key in values:
94
+ value = values[key]
95
+ if any(char in value for char in "\n\r\0"):
96
+ raise ConfigError(f"{key}: value must be a single line")
97
+ lines.append(f"{key}={shlex.quote(value)}")
98
+ return "\n".join(lines) + "\n"
99
+
100
+
101
+ def validate_domain(value: str) -> str:
102
+ parts = urlsplit(value)
103
+ if (
104
+ parts.scheme not in ("http", "https")
105
+ or not parts.hostname
106
+ or parts.username
107
+ or parts.password
108
+ or parts.query
109
+ or parts.fragment
110
+ or any(char.isspace() for char in value)
111
+ ):
112
+ raise ConfigError("vault URL must be http(s)://host[:port][/path] without credentials")
113
+ return value.rstrip("/")
114
+
115
+
116
+ def is_unencrypted_remote(domain: str) -> bool:
117
+ parts = urlsplit(domain)
118
+ if parts.scheme != "http":
119
+ return False
120
+ host = parts.hostname or ""
121
+ if host == "localhost":
122
+ return False
123
+ try:
124
+ return not ipaddress.ip_address(host).is_loopback
125
+ except ValueError:
126
+ return True
127
+
128
+
129
+ def validate_project_id(value: str) -> str:
130
+ if not PROJECT_ID.fullmatch(value):
131
+ raise ConfigError("project ID may contain only letters, digits, '-' and '_'")
132
+ return value
133
+
134
+
135
+ def validate_vault_path(value: str) -> str:
136
+ if not VAULT_PATH.fullmatch(value) or "//" in value or "/../" in value + "/":
137
+ raise ConfigError("vault folder path must be absolute, for example / or /app")
138
+ return value
139
+
140
+
141
+ def validate_token(value: str) -> str:
142
+ if not value or any(char.isspace() or char == "\0" for char in value):
143
+ raise ConfigError("token must be one non-empty line without spaces; nothing was changed")
144
+ return value
145
+
146
+
147
+ def validate_executable(value: str) -> str:
148
+ path = Path(value)
149
+ if not path.is_absolute() or not path.is_file() or not os.access(path, os.X_OK):
150
+ raise ConfigError(f"{value} is not an absolute path to an executable file")
151
+ return value
152
+
153
+
154
+ def read_env(path: Path, owner_uid: int) -> dict[str, str]:
155
+ try:
156
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
157
+ except FileNotFoundError:
158
+ raise ConfigError(f"{path} does not exist; run the installer first") from None
159
+ except OSError:
160
+ raise ConfigError(f"{path} cannot be opened (symlinks are refused)") from None
161
+ with os.fdopen(fd, encoding="utf-8") as handle:
162
+ info = os.fstat(handle.fileno())
163
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != owner_uid or info.st_mode & 0o077:
164
+ raise ConfigError(f"{path} must be a regular file owned by the broker user, mode 0600")
165
+ try:
166
+ return parse(handle.read())
167
+ except UnicodeDecodeError:
168
+ raise ConfigError(f"{path} is not valid UTF-8") from None
169
+
170
+
171
+ def write_env(path: Path, values: dict[str, str], owner_uid: int, owner_gid: int) -> None:
172
+ content = serialize(values)
173
+ fd, temp_name = tempfile.mkstemp(prefix=".broker.env.", dir=path.parent)
174
+ try:
175
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
176
+ os.fchmod(handle.fileno(), 0o600)
177
+ os.fchown(handle.fileno(), owner_uid, owner_gid)
178
+ handle.write(content)
179
+ handle.flush()
180
+ os.fsync(handle.fileno())
181
+ os.replace(temp_name, path)
182
+ temp_name = None
183
+ directory = os.open(path.parent, os.O_RDONLY)
184
+ try:
185
+ os.fsync(directory)
186
+ finally:
187
+ os.close(directory)
188
+ finally:
189
+ if temp_name is not None:
190
+ os.unlink(temp_name)
191
+
192
+
193
+ def hidden_input(prompt: str) -> str:
194
+ """Read the token with echo off, or refuse before reading anything.
195
+
196
+ getpass falls back to visible input (with a GetPassWarning) when it
197
+ cannot turn echo off; that warning is raised as an error here, before
198
+ the fallback reads a single character.
199
+ """
200
+ with warnings.catch_warnings():
201
+ warnings.simplefilter("error", getpass.GetPassWarning)
202
+ try:
203
+ return getpass.getpass(prompt)
204
+ except getpass.GetPassWarning:
205
+ raise ConfigError("hidden input is not available on this terminal; nothing was changed") from None
206
+
207
+
208
+ def ask(prompt: str, current: str, validate: Callable[[str], str], read: Callable[[str], str]) -> str:
209
+ shown = f" [{current}]" if current else ""
210
+ answer = read(f"{prompt}{shown}: ").strip() or current
211
+ if not answer:
212
+ raise ConfigError(f"{prompt} is required; nothing was changed")
213
+ return validate(answer)
214
+
215
+
216
+ def configure(
217
+ values: dict[str, str],
218
+ *,
219
+ token_only: bool,
220
+ read: Callable[[str], str] = input,
221
+ read_hidden: Callable[[str], str] = hidden_input,
222
+ ) -> dict[str, str]:
223
+ updated = dict(values)
224
+ if not token_only:
225
+ updated["INFISICAL_DOMAIN"] = ask(
226
+ "Vault URL", values.get("INFISICAL_DOMAIN", ""), validate_domain, read
227
+ )
228
+ updated["SECRETOPS_INFISICAL_PROJECT_ID"] = ask(
229
+ "Project ID", values.get("SECRETOPS_INFISICAL_PROJECT_ID", ""), validate_project_id, read
230
+ )
231
+ updated["SECRETOPS_INFISICAL_PATH"] = ask(
232
+ "Vault folder path", values.get("SECRETOPS_INFISICAL_PATH", "/"), validate_vault_path, read
233
+ )
234
+ has_token = bool(values.get(TOKEN_KEY))
235
+ hint = " (hidden; Enter keeps the current token)" if has_token and not token_only else " (hidden)"
236
+ token = read_hidden(f"Machine identity access token{hint}: ")
237
+ if token or token_only or not has_token:
238
+ updated[TOKEN_KEY] = validate_token(token)
239
+ return updated
240
+
241
+
242
+ def init_values(domain: str, infisical_bin: str, gh_bin: str | None) -> dict[str, str]:
243
+ values = dict(LAYOUT_DEFAULTS)
244
+ values["INFISICAL_DOMAIN"] = validate_domain(domain)
245
+ values["SECRETOPS_INFISICAL_BIN"] = validate_executable(infisical_bin)
246
+ if gh_bin:
247
+ values["SECRETOPS_GH_BIN"] = validate_executable(gh_bin)
248
+ return values
249
+
250
+
251
+ def main(argv: list[str] | None = None) -> int:
252
+ parser = argparse.ArgumentParser(prog="secretops-configure", description=__doc__.split("\n\n")[0])
253
+ parser.add_argument("--token-only", action="store_true", help="replace only the access token")
254
+ parser.add_argument("--init", action="store_true", help=argparse.SUPPRESS)
255
+ parser.add_argument("--domain", help=argparse.SUPPRESS)
256
+ parser.add_argument("--infisical-bin", help=argparse.SUPPRESS)
257
+ parser.add_argument("--gh-bin", help=argparse.SUPPRESS)
258
+ args = parser.parse_args(argv)
259
+ try:
260
+ if os.geteuid() != 0:
261
+ raise ConfigError("run with sudo from your own terminal, not from an agent session")
262
+ account = pwd.getpwnam(BROKER_USER)
263
+ if args.init:
264
+ if BROKER_ENV.exists() or BROKER_ENV.is_symlink():
265
+ raise ConfigError(f"{BROKER_ENV} already exists")
266
+ if not args.domain or not args.infisical_bin:
267
+ raise ConfigError("--init needs --domain and --infisical-bin")
268
+ write_env(BROKER_ENV, init_values(args.domain, args.infisical_bin, args.gh_bin),
269
+ account.pw_uid, account.pw_gid)
270
+ return 0
271
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
272
+ raise ConfigError("an interactive terminal is required; do not pipe credentials")
273
+ values = read_env(BROKER_ENV, account.pw_uid)
274
+ updated = configure(values, token_only=args.token_only)
275
+ write_env(BROKER_ENV, updated, account.pw_uid, account.pw_gid)
276
+ except ConfigError as error:
277
+ print(f"secretops-configure: {error}", file=sys.stderr)
278
+ return 1
279
+ except KeyError:
280
+ print(f"secretops-configure: broker user {BROKER_USER} does not exist", file=sys.stderr)
281
+ return 1
282
+ except (EOFError, KeyboardInterrupt):
283
+ print("\nsecretops-configure: cancelled; nothing was changed", file=sys.stderr)
284
+ return 2
285
+ print("Broker configuration saved. No credential was displayed.")
286
+ if is_unencrypted_remote(updated["INFISICAL_DOMAIN"]):
287
+ print("WARNING: the vault URL uses unencrypted HTTP to a non-loopback host.")
288
+ print("Check it: sudo -u secretops-broker /usr/local/bin/secretops-broker "
289
+ "check-vault-read --name <EXISTING_TEST_SECRET> --env dev")
290
+ return 0
291
+
292
+
293
+ if __name__ == "__main__":
294
+ raise SystemExit(main())