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 +2 -0
- secretops/add_client.py +354 -0
- secretops/audit.py +42 -0
- secretops/broker_config.py +294 -0
- secretops/cli.py +329 -0
- secretops/generators.py +17 -0
- secretops/handlers.py +319 -0
- secretops/human_entry.py +373 -0
- secretops/import_file.py +78 -0
- secretops/plan_lifecycle.py +1574 -0
- secretops/plans.py +585 -0
- secretops/policies/__init__.py +1 -0
- secretops/policies/default.yaml +90 -0
- secretops/policy.py +103 -0
- secretops/recipes.py +99 -0
- secretops/targets/__init__.py +25 -0
- secretops/targets/base.py +8 -0
- secretops/targets/github.py +42 -0
- secretops/targets/github_cli.py +295 -0
- secretops/terminal_input.py +292 -0
- secretops/validation.py +137 -0
- secretops/vaults/__init__.py +45 -0
- secretops/vaults/base.py +99 -0
- secretops/vaults/infisical_cli.py +367 -0
- secretops-0.1.0.dist-info/METADATA +390 -0
- secretops-0.1.0.dist-info/RECORD +30 -0
- secretops-0.1.0.dist-info/WHEEL +4 -0
- secretops-0.1.0.dist-info/entry_points.txt +3 -0
- secretops-0.1.0.dist-info/licenses/LICENSE +202 -0
- secretops-0.1.0.dist-info/licenses/NOTICE +2 -0
secretops/__init__.py
ADDED
secretops/add_client.py
ADDED
|
@@ -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())
|