tripwire-cli 0.2.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.
- tripwire_cli/__init__.py +0 -0
- tripwire_cli/__main__.py +4 -0
- tripwire_cli/cli.py +365 -0
- tripwire_cli/client.py +162 -0
- tripwire_cli/credentials.py +68 -0
- tripwire_cli-0.2.0.dist-info/METADATA +68 -0
- tripwire_cli-0.2.0.dist-info/RECORD +9 -0
- tripwire_cli-0.2.0.dist-info/WHEEL +4 -0
- tripwire_cli-0.2.0.dist-info/entry_points.txt +3 -0
tripwire_cli/__init__.py
ADDED
|
File without changes
|
tripwire_cli/__main__.py
ADDED
tripwire_cli/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Command-line client for Tripwire canaries.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
tripwire login log in (email-code by default), cache a
|
|
5
|
+
token; --user-id/--password for operators
|
|
6
|
+
tripwire logout forget the cached token
|
|
7
|
+
tripwire whoami print the cached identity
|
|
8
|
+
tripwire canaries list list your canaries (summary only)
|
|
9
|
+
tripwire canaries get <id> show one canary summary
|
|
10
|
+
tripwire canaries create --type ... create a canary; its credential is
|
|
11
|
+
returned once, in this response
|
|
12
|
+
tripwire canaries deactivate <id> deactivate a canary
|
|
13
|
+
tripwire canaries delete <id> delete a canary
|
|
14
|
+
|
|
15
|
+
`login` does not take a server flag. The server is resolved as
|
|
16
|
+
$TRIPWIRE_SERVER, then the last-used cached server, then the default; set
|
|
17
|
+
$TRIPWIRE_SERVER to point at a self-hosted or test server.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import functools
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
|
|
31
|
+
from tripwire_cli import credentials
|
|
32
|
+
from tripwire_cli.client import CREATE_READ_TIMEOUT, ApiClient, ApiError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git_user_email() -> str | None:
|
|
36
|
+
"""Best-effort default email from ``git config user.email``; ``None`` if
|
|
37
|
+
git is absent or unset. Shown to the user as a default, never used
|
|
38
|
+
silently."""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git", "config", "user.email"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=5,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, subprocess.SubprocessError):
|
|
47
|
+
return None
|
|
48
|
+
email = result.stdout.strip()
|
|
49
|
+
return email or None
|
|
50
|
+
|
|
51
|
+
DEFAULT_SERVER = "https://tripwire.so/api/v1"
|
|
52
|
+
|
|
53
|
+
CANARY_TYPES = ["dns_label", "aws_access_key", "anthropic_api_key", "github_pat"]
|
|
54
|
+
|
|
55
|
+
# How long the create read timeout may run, in seconds. Must stay above the
|
|
56
|
+
# server's synchronous create wait window (180s) so the client never abandons a
|
|
57
|
+
# request whose one-time credential reveal the server is still preparing.
|
|
58
|
+
CREATE_TIMEOUT_ENV = "TRIPWIRE_CREATE_TIMEOUT"
|
|
59
|
+
|
|
60
|
+
# How many times to re-prompt for the emailed code before giving up. Re-prompts
|
|
61
|
+
# never re-call /auth/login/start (which is rate-limited); they re-submit a new
|
|
62
|
+
# code against the same challenge.
|
|
63
|
+
EMAIL_CODE_ATTEMPTS = 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_login_server(env: dict[str, str], cached: str | None) -> str:
|
|
67
|
+
"""Server URL for `login`: env override, else the last-used cached server,
|
|
68
|
+
else the default."""
|
|
69
|
+
return env.get("TRIPWIRE_SERVER") or cached or DEFAULT_SERVER
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_create_payload(*, canary_type: str, memo: str | None = None) -> dict[str, Any]:
|
|
73
|
+
"""Build the create request body from the supported flags."""
|
|
74
|
+
payload: dict[str, Any] = {"type": canary_type}
|
|
75
|
+
if memo:
|
|
76
|
+
payload["memo"] = memo
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Context:
|
|
81
|
+
"""Shared state for the commands: the credential store and a factory that
|
|
82
|
+
builds an :class:`ApiClient` from a server URL and optional token. Both are
|
|
83
|
+
injectable so tests can supply fakes."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
store: credentials.CredentialStore | None = None,
|
|
88
|
+
client_factory: Callable[[str, str | None], ApiClient] | None = None,
|
|
89
|
+
git_email: Callable[[], str | None] | None = None,
|
|
90
|
+
):
|
|
91
|
+
self.store = store or credentials.default_store()
|
|
92
|
+
self._client_factory = client_factory or (
|
|
93
|
+
lambda server, token=None: ApiClient(base_url=server, token=token)
|
|
94
|
+
)
|
|
95
|
+
# Injectable so tests can supply a fake; defaults to `git config
|
|
96
|
+
# user.email`.
|
|
97
|
+
self._git_email = git_email or _git_user_email
|
|
98
|
+
|
|
99
|
+
def git_email(self) -> str | None:
|
|
100
|
+
return self._git_email()
|
|
101
|
+
|
|
102
|
+
def client(self, server: str, token: str | None = None) -> ApiClient:
|
|
103
|
+
return self._client_factory(server, token)
|
|
104
|
+
|
|
105
|
+
def authed_client(self) -> ApiClient:
|
|
106
|
+
creds = self.store.load()
|
|
107
|
+
return self.client(creds.server, creds.access_token)
|
|
108
|
+
|
|
109
|
+
def cached_server(self) -> str | None:
|
|
110
|
+
try:
|
|
111
|
+
return self.store.load().server
|
|
112
|
+
except credentials.NoCredentialsError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _handle_errors(func):
|
|
117
|
+
"""Translate API/credential errors into a clean CLI error and exit code."""
|
|
118
|
+
|
|
119
|
+
@functools.wraps(func)
|
|
120
|
+
def wrapper(*args, **kwargs):
|
|
121
|
+
try:
|
|
122
|
+
return func(*args, **kwargs)
|
|
123
|
+
except ApiError as exc:
|
|
124
|
+
message = f"{exc.status}: {exc.detail}"
|
|
125
|
+
if exc.status == 401:
|
|
126
|
+
message += "\nhint: run `tripwire login`"
|
|
127
|
+
raise click.ClickException(message) from exc
|
|
128
|
+
except (credentials.NoCredentialsError, ValueError) as exc:
|
|
129
|
+
raise click.ClickException(str(exc)) from exc
|
|
130
|
+
|
|
131
|
+
return wrapper
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _echo_json(value: Any) -> None:
|
|
135
|
+
click.echo(json.dumps(value, indent=2))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@click.group()
|
|
139
|
+
@click.version_option(package_name="tripwire-cli", prog_name="tripwire")
|
|
140
|
+
@click.pass_context
|
|
141
|
+
def cli(ctx: click.Context) -> None:
|
|
142
|
+
"""Tripwire canary client."""
|
|
143
|
+
ctx.obj = ctx.obj or Context()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.command()
|
|
147
|
+
@click.option("--user-id", help="operator user id (selects password login)")
|
|
148
|
+
@click.option(
|
|
149
|
+
"--password", help="operator password (selects password login; prefer the prompt)"
|
|
150
|
+
)
|
|
151
|
+
@click.pass_obj
|
|
152
|
+
@_handle_errors
|
|
153
|
+
def login(obj: Context, user_id: str | None, password: str | None) -> None:
|
|
154
|
+
"""Log in and cache a token.
|
|
155
|
+
|
|
156
|
+
Defaults to passwordless email-code login. Passing --user-id or --password
|
|
157
|
+
selects the operator (user-id + password) login instead, so operator
|
|
158
|
+
scripts keep working unchanged.
|
|
159
|
+
"""
|
|
160
|
+
server = resolve_login_server(dict(os.environ), obj.cached_server())
|
|
161
|
+
client = obj.client(server)
|
|
162
|
+
if user_id is not None or password is not None:
|
|
163
|
+
creds = _password_login(client, server, user_id, password)
|
|
164
|
+
else:
|
|
165
|
+
creds = _email_login(client, server, obj.git_email())
|
|
166
|
+
path = obj.store.save(creds)
|
|
167
|
+
click.echo(f"logged in as {creds.user_id} ({creds.role}); token cached at {path}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _password_login(
|
|
171
|
+
client: ApiClient, server: str, user_id: str | None, password: str | None
|
|
172
|
+
) -> credentials.Credentials:
|
|
173
|
+
"""Operator login: user-id + password."""
|
|
174
|
+
user_id = user_id or click.prompt("user_id")
|
|
175
|
+
password = password or click.prompt("password", hide_input=True)
|
|
176
|
+
response = client.login(user_id, password)
|
|
177
|
+
return _credentials_from_login(server, response)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _email_login(
|
|
181
|
+
client: ApiClient, server: str, default_email: str | None
|
|
182
|
+
) -> credentials.Credentials:
|
|
183
|
+
"""Passwordless email-code login. Calls /auth/login/start once (it is
|
|
184
|
+
rate-limited), then re-prompts for the 6-digit code in-band on an
|
|
185
|
+
invalid/expired code without re-calling start."""
|
|
186
|
+
email = click.prompt("email", default=default_email)
|
|
187
|
+
client.login_start(email)
|
|
188
|
+
click.echo(f"sent a 6-digit sign-in code to {email}; check your inbox.")
|
|
189
|
+
last_error: ApiError | None = None
|
|
190
|
+
for attempt in range(EMAIL_CODE_ATTEMPTS):
|
|
191
|
+
code = click.prompt("code")
|
|
192
|
+
try:
|
|
193
|
+
response = client.login_with_code(email, code)
|
|
194
|
+
except ApiError as exc:
|
|
195
|
+
if exc.status == 400 and exc.detail == "invalid_or_expired_code":
|
|
196
|
+
last_error = exc
|
|
197
|
+
remaining = EMAIL_CODE_ATTEMPTS - attempt - 1
|
|
198
|
+
if remaining:
|
|
199
|
+
click.echo(
|
|
200
|
+
f"invalid or expired code; {remaining} attempt(s) left.",
|
|
201
|
+
err=True,
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
raise
|
|
205
|
+
return _credentials_from_login(server, response, email=email)
|
|
206
|
+
# Exhausted the re-prompt budget; surface the last invalid-code error.
|
|
207
|
+
raise last_error if last_error is not None else ApiError(400, "invalid_or_expired_code")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _credentials_from_login(
|
|
211
|
+
server: str, response: dict[str, Any], email: str | None = None
|
|
212
|
+
) -> credentials.Credentials:
|
|
213
|
+
return credentials.Credentials(
|
|
214
|
+
server=server,
|
|
215
|
+
user_id=response["user_id"],
|
|
216
|
+
access_token=response["access_token"],
|
|
217
|
+
expires_at=int(response["expires_at"]),
|
|
218
|
+
role=response["role"],
|
|
219
|
+
email=email,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@cli.command()
|
|
224
|
+
@click.pass_obj
|
|
225
|
+
@_handle_errors
|
|
226
|
+
def logout(obj: Context) -> None:
|
|
227
|
+
"""Forget the cached token."""
|
|
228
|
+
click.echo("cached token removed" if obj.store.clear() else "no cached token")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@cli.command()
|
|
232
|
+
@click.pass_obj
|
|
233
|
+
@_handle_errors
|
|
234
|
+
def whoami(obj: Context) -> None:
|
|
235
|
+
"""Print the cached identity."""
|
|
236
|
+
creds = obj.store.load()
|
|
237
|
+
click.echo(f"server: {creds.server}")
|
|
238
|
+
click.echo(f"user_id: {creds.user_id}")
|
|
239
|
+
if creds.email:
|
|
240
|
+
click.echo(f"email: {creds.email}")
|
|
241
|
+
click.echo(f"role: {creds.role}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@cli.group()
|
|
245
|
+
def canaries() -> None:
|
|
246
|
+
"""Manage canaries."""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@canaries.command("list")
|
|
250
|
+
@click.pass_obj
|
|
251
|
+
@_handle_errors
|
|
252
|
+
def canaries_list(obj: Context) -> None:
|
|
253
|
+
"""List the canaries you own (summary only)."""
|
|
254
|
+
_echo_json(obj.authed_client().list_canaries())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@canaries.command("get")
|
|
258
|
+
@click.argument("canary_id")
|
|
259
|
+
@click.pass_obj
|
|
260
|
+
@_handle_errors
|
|
261
|
+
def canaries_get(obj: Context, canary_id: str) -> None:
|
|
262
|
+
"""Show one canary summary (no credential)."""
|
|
263
|
+
_echo_json(obj.authed_client().get_canary(canary_id))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@canaries.command("create")
|
|
267
|
+
@click.option(
|
|
268
|
+
"--type", "canary_type", type=click.Choice(CANARY_TYPES), required=True
|
|
269
|
+
)
|
|
270
|
+
@click.option("--memo", help="free-form note about this canary")
|
|
271
|
+
@click.option(
|
|
272
|
+
"--timeout",
|
|
273
|
+
"timeout",
|
|
274
|
+
type=float,
|
|
275
|
+
default=None,
|
|
276
|
+
help=(
|
|
277
|
+
"read timeout in seconds for this create "
|
|
278
|
+
f"(env {CREATE_TIMEOUT_ENV}; default 240). Must exceed the server's "
|
|
279
|
+
"~180s provisioning wait."
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
@click.pass_obj
|
|
283
|
+
@_handle_errors
|
|
284
|
+
def canaries_create(
|
|
285
|
+
obj: Context, canary_type: str, memo: str | None, timeout: float | None
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Create a canary. The credential is returned once, in this response."""
|
|
288
|
+
read_timeout = _resolve_create_timeout(timeout, dict(os.environ))
|
|
289
|
+
payload = build_create_payload(canary_type=canary_type, memo=memo)
|
|
290
|
+
click.echo(
|
|
291
|
+
"creating the canary (usually a second or two; up to ~2 min if the "
|
|
292
|
+
"warm pool is cold).",
|
|
293
|
+
err=True,
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
result = obj.authed_client().create_canary(payload, timeout=read_timeout)
|
|
297
|
+
except ApiError as exc:
|
|
298
|
+
message = _create_error_message(exc)
|
|
299
|
+
if message is None:
|
|
300
|
+
raise
|
|
301
|
+
raise click.ClickException(message) from exc
|
|
302
|
+
_echo_json(result)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_create_timeout(flag: float | None, env: dict[str, str]) -> float:
|
|
306
|
+
"""Read timeout for create, in seconds: --timeout flag, else
|
|
307
|
+
``TRIPWIRE_CREATE_TIMEOUT`` env, else ``CREATE_READ_TIMEOUT`` (240). Always
|
|
308
|
+
above the server's ~180s provisioning wait so the client never abandons a
|
|
309
|
+
create whose one-time reveal is still being prepared."""
|
|
310
|
+
if flag is not None:
|
|
311
|
+
return flag
|
|
312
|
+
raw = env.get(CREATE_TIMEOUT_ENV)
|
|
313
|
+
if not raw:
|
|
314
|
+
return CREATE_READ_TIMEOUT
|
|
315
|
+
try:
|
|
316
|
+
return float(raw)
|
|
317
|
+
except ValueError as exc:
|
|
318
|
+
raise click.ClickException(
|
|
319
|
+
f"invalid {CREATE_TIMEOUT_ENV}={raw!r}: must be a number of seconds"
|
|
320
|
+
) from exc
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _create_error_message(exc: ApiError) -> str | None:
|
|
324
|
+
"""Friendly text for the create-specific failures, or ``None`` to fall back
|
|
325
|
+
to the generic error handler."""
|
|
326
|
+
if exc.status == 429 and exc.detail == "canary_pending":
|
|
327
|
+
return (
|
|
328
|
+
"canary is still provisioning, so its one-time credential reveal was "
|
|
329
|
+
"not returned in this response and cannot be recovered. creating "
|
|
330
|
+
"again would mint a second canary and trip the per-type quota; "
|
|
331
|
+
"instead, find this orphan with `tripwire canaries list`, delete it "
|
|
332
|
+
"with `tripwire canaries delete <id>`, then recreate."
|
|
333
|
+
)
|
|
334
|
+
if exc.status == 502 and exc.detail == "provisioning_failed":
|
|
335
|
+
return (
|
|
336
|
+
"canary provisioning failed; nothing was issued. "
|
|
337
|
+
"try again, and if it persists contact support."
|
|
338
|
+
)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@canaries.command("deactivate")
|
|
343
|
+
@click.argument("canary_id")
|
|
344
|
+
@click.pass_obj
|
|
345
|
+
@_handle_errors
|
|
346
|
+
def canaries_deactivate(obj: Context, canary_id: str) -> None:
|
|
347
|
+
"""Deactivate a canary."""
|
|
348
|
+
_echo_json(obj.authed_client().deactivate_canary(canary_id))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@canaries.command("delete")
|
|
352
|
+
@click.argument("canary_id")
|
|
353
|
+
@click.pass_obj
|
|
354
|
+
@_handle_errors
|
|
355
|
+
def canaries_delete(obj: Context, canary_id: str) -> None:
|
|
356
|
+
"""Delete a canary."""
|
|
357
|
+
_echo_json(obj.authed_client().delete_canary(canary_id))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main() -> None:
|
|
361
|
+
cli(prog_name="tripwire")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|
tripwire_cli/client.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""HTTP client for the Tripwire REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT = 10.0
|
|
11
|
+
|
|
12
|
+
# Connecting should fail fast even when a single request is allowed a long read
|
|
13
|
+
# window, so an unreachable server never hangs for the full read timeout.
|
|
14
|
+
CONNECT_TIMEOUT = 5.0
|
|
15
|
+
|
|
16
|
+
# `POST /canary` is synchronous and provider-minted types can take ~12s/44s/100s
|
|
17
|
+
# today; the server waits up to ``CANARY_CREATE_WAIT_SECONDS`` (180s) before it
|
|
18
|
+
# gives up. The client read timeout MUST stay above that window: if the client
|
|
19
|
+
# abandons the request first, the server still creates the canary, the one-time
|
|
20
|
+
# credential reveal is lost, and the cap-1 quota is consumed with no recovery.
|
|
21
|
+
CREATE_READ_TIMEOUT = 240.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(Exception):
|
|
25
|
+
"""A non-2xx response, or a failure to reach the server (``status == 0``)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, detail: str):
|
|
28
|
+
super().__init__(f"{status}: {detail}")
|
|
29
|
+
self.status = status
|
|
30
|
+
self.detail = detail
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiClient:
|
|
34
|
+
"""Thin client over the Tripwire REST API.
|
|
35
|
+
|
|
36
|
+
Pass ``http_client`` to supply your own configured ``httpx.Client``
|
|
37
|
+
(handy for tests and custom transports); otherwise one is created.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str,
|
|
44
|
+
token: str | None = None,
|
|
45
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
46
|
+
http_client: httpx.Client | None = None,
|
|
47
|
+
):
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self.token = token
|
|
50
|
+
# Short connect everywhere (a hung connect should fail fast); the
|
|
51
|
+
# read/write/pool budget is ``timeout``. The create path raises its
|
|
52
|
+
# read budget per-request via ``_request(timeout=...)``.
|
|
53
|
+
self._client = http_client or httpx.Client(
|
|
54
|
+
timeout=httpx.Timeout(timeout, connect=CONNECT_TIMEOUT)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> ApiClient:
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(
|
|
61
|
+
self,
|
|
62
|
+
exc_type: type[BaseException] | None,
|
|
63
|
+
exc: BaseException | None,
|
|
64
|
+
tb: TracebackType | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
self._client.close()
|
|
70
|
+
|
|
71
|
+
def _headers(self) -> dict[str, str]:
|
|
72
|
+
headers = {"accept": "application/json"}
|
|
73
|
+
if self.token:
|
|
74
|
+
headers["authorization"] = f"Bearer {self.token}"
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
def _request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str,
|
|
81
|
+
payload: dict[str, Any] | None = None,
|
|
82
|
+
*,
|
|
83
|
+
timeout: float | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
kwargs: dict[str, Any] = {}
|
|
86
|
+
if timeout is not None:
|
|
87
|
+
# Per-request override: long read window, short connect so an
|
|
88
|
+
# unreachable server still fails fast.
|
|
89
|
+
kwargs["timeout"] = httpx.Timeout(timeout, connect=CONNECT_TIMEOUT)
|
|
90
|
+
try:
|
|
91
|
+
response = self._client.request(
|
|
92
|
+
method,
|
|
93
|
+
self.base_url + path,
|
|
94
|
+
json=payload,
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
**kwargs,
|
|
97
|
+
)
|
|
98
|
+
except httpx.RequestError as exc:
|
|
99
|
+
raise ApiError(0, f"cannot reach {self.base_url}: {exc}") from exc
|
|
100
|
+
if response.is_error:
|
|
101
|
+
raise ApiError(response.status_code, _error_detail(response))
|
|
102
|
+
if not response.content:
|
|
103
|
+
return {}
|
|
104
|
+
return response.json()
|
|
105
|
+
|
|
106
|
+
def login(self, user_id: str, password: str) -> dict[str, Any]:
|
|
107
|
+
return self._request(
|
|
108
|
+
"POST", "/auth/login", {"user_id": user_id, "password": password}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def login_start(self, email: str) -> dict[str, Any]:
|
|
112
|
+
"""Begin an email-code login: the server emails a 6-digit code. The
|
|
113
|
+
response is intentionally neutral (``{"status": "ok"}``) and never
|
|
114
|
+
reveals whether the address is known. This endpoint is rate-limited, so
|
|
115
|
+
call it once per login and re-prompt for the code in-band on failure."""
|
|
116
|
+
return self._request("POST", "/auth/login/start", {"email": email})
|
|
117
|
+
|
|
118
|
+
def login_with_code(self, email: str, code: str) -> dict[str, Any]:
|
|
119
|
+
"""Exchange an emailed 6-digit code for a token. A wrong/expired/used
|
|
120
|
+
code returns ``400 invalid_or_expired_code``."""
|
|
121
|
+
return self._request("POST", "/auth/login", {"email": email, "code": code})
|
|
122
|
+
|
|
123
|
+
def list_canaries(self) -> dict[str, Any]:
|
|
124
|
+
return self._request("GET", "/canary")
|
|
125
|
+
|
|
126
|
+
def create_canary(
|
|
127
|
+
self, payload: dict[str, Any], *, timeout: float | None = None
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Create a canary. The response carries the credential inline; this is
|
|
130
|
+
the only time it is returned, so capture it from the result.
|
|
131
|
+
|
|
132
|
+
``timeout`` is the read timeout for this one request (defaults to
|
|
133
|
+
``CREATE_READ_TIMEOUT``). It must stay above the server's synchronous
|
|
134
|
+
create wait window, or the client gives up while the server is still
|
|
135
|
+
provisioning and the one-time reveal is lost."""
|
|
136
|
+
return self._request(
|
|
137
|
+
"POST",
|
|
138
|
+
"/canary",
|
|
139
|
+
payload,
|
|
140
|
+
timeout=CREATE_READ_TIMEOUT if timeout is None else timeout,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def get_canary(self, canary_id: str) -> dict[str, Any]:
|
|
144
|
+
return self._request("GET", f"/canary/{canary_id}")
|
|
145
|
+
|
|
146
|
+
def deactivate_canary(self, canary_id: str) -> dict[str, Any]:
|
|
147
|
+
return self._request("POST", f"/canary/{canary_id}/deactivate")
|
|
148
|
+
|
|
149
|
+
def delete_canary(self, canary_id: str) -> dict[str, Any]:
|
|
150
|
+
return self._request("DELETE", f"/canary/{canary_id}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
154
|
+
"""Best-effort human-readable detail from an error response: the JSON
|
|
155
|
+
``detail`` field when present, else the raw body or status reason."""
|
|
156
|
+
try:
|
|
157
|
+
body = response.json()
|
|
158
|
+
except ValueError:
|
|
159
|
+
return response.text or response.reason_phrase
|
|
160
|
+
if isinstance(body, dict) and "detail" in body:
|
|
161
|
+
return str(body["detail"])
|
|
162
|
+
return response.text or response.reason_phrase
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Local credential cache for the Tripwire CLI.
|
|
2
|
+
|
|
3
|
+
The token, server URL, and identity are kept in a single JSON file, by default
|
|
4
|
+
``~/.config/tripwire/credentials.json`` (honoring ``XDG_CONFIG_HOME``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, kw_only=True)
|
|
18
|
+
class Credentials:
|
|
19
|
+
server: str
|
|
20
|
+
user_id: str
|
|
21
|
+
access_token: str
|
|
22
|
+
expires_at: int
|
|
23
|
+
role: str
|
|
24
|
+
# Optional: present for email-code logins, absent for operator
|
|
25
|
+
# (user-id/password) logins and for caches written by older CLIs.
|
|
26
|
+
email: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NoCredentialsError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_path() -> Path:
|
|
34
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
35
|
+
return Path(base) / "tripwire" / "credentials.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class CredentialStore:
|
|
40
|
+
"""Read/write the cached credentials at ``path``."""
|
|
41
|
+
|
|
42
|
+
path: Path
|
|
43
|
+
|
|
44
|
+
def load(self) -> Credentials:
|
|
45
|
+
if not self.path.exists():
|
|
46
|
+
raise NoCredentialsError("not logged in (run `tripwire login`)")
|
|
47
|
+
data = json.loads(self.path.read_text())
|
|
48
|
+
# Forward-compatible: keep only the fields this CLI knows about, so an
|
|
49
|
+
# older CLI reading a cache written by a newer one does not crash on an
|
|
50
|
+
# unexpected keyword argument.
|
|
51
|
+
known = {f.name for f in dataclasses.fields(Credentials)}
|
|
52
|
+
return Credentials(**{k: v for k, v in data.items() if k in known})
|
|
53
|
+
|
|
54
|
+
def save(self, credentials: Credentials) -> Path:
|
|
55
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
self.path.write_text(json.dumps(asdict(credentials), indent=2))
|
|
57
|
+
self.path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
58
|
+
return self.path
|
|
59
|
+
|
|
60
|
+
def clear(self) -> bool:
|
|
61
|
+
if not self.path.exists():
|
|
62
|
+
return False
|
|
63
|
+
self.path.unlink()
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_store() -> CredentialStore:
|
|
68
|
+
return CredentialStore(default_path())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tripwire-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Command-line client for Tripwire canaries
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: click>=8.1
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# tripwire-cli
|
|
11
|
+
|
|
12
|
+
Command-line client for [Tripwire](https://tripwire.so) canaries. Installs a
|
|
13
|
+
single `tripwire` command.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install --from . tripwire-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or run without installing:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx --from . tripwire --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Log in and cache a token. Defaults to passwordless email-code login: it
|
|
31
|
+
# prompts for your email (defaulting to `git config user.email`) and the
|
|
32
|
+
# 6-digit code emailed to you. Operators can pass --user-id / --password to use
|
|
33
|
+
# the user-id + password login instead.
|
|
34
|
+
tripwire login
|
|
35
|
+
|
|
36
|
+
# Create a canary. The credential is returned once, in this response, so
|
|
37
|
+
# capture it now. Provider-minted types (aws/anthropic/github) can take ~2 min;
|
|
38
|
+
# the CLI waits and prints a progress note while it provisions.
|
|
39
|
+
tripwire canaries create --type dns_label --memo "env metrics host"
|
|
40
|
+
tripwire canaries create --type aws_access_key --memo "warehouse reporting key"
|
|
41
|
+
|
|
42
|
+
# Inspect what you own (summaries only; the credential is never shown again).
|
|
43
|
+
tripwire canaries list
|
|
44
|
+
tripwire canaries get can_1234abcd
|
|
45
|
+
|
|
46
|
+
# Wind one down.
|
|
47
|
+
tripwire canaries deactivate can_1234abcd
|
|
48
|
+
tripwire canaries delete can_1234abcd
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`canaries` subcommands print JSON to stdout (pipe to `jq`); progress and other
|
|
52
|
+
plain-text messages go to stderr, so stdout stays clean JSON. Run
|
|
53
|
+
`tripwire --help` for the full reference.
|
|
54
|
+
|
|
55
|
+
Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
|
|
56
|
+
and `github_pat`.
|
|
57
|
+
|
|
58
|
+
`canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
|
|
59
|
+
default 240) for the per-request read timeout; it must stay above the server's
|
|
60
|
+
~180s provisioning wait so the one-time credential reveal is never lost to a
|
|
61
|
+
premature client timeout.
|
|
62
|
+
|
|
63
|
+
## Server
|
|
64
|
+
|
|
65
|
+
`login` talks to `https://tripwire.so/api/v1` by default. Set `TRIPWIRE_SERVER`
|
|
66
|
+
to point at a self-hosted or test server before logging in; the server is bound
|
|
67
|
+
to your token at login time. The token is cached at
|
|
68
|
+
`~/.config/tripwire/credentials.json` (honoring `XDG_CONFIG_HOME`).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
tripwire_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tripwire_cli/__main__.py,sha256=BOlVJVISJ_-zRpgR_nxg5GYdXHHzXuQp4FcZrZ-Epuk,73
|
|
3
|
+
tripwire_cli/cli.py,sha256=XlBF1X2zqHyhOP7maQMpqoA74sIqh9t-ZuqevTpP5AY,12655
|
|
4
|
+
tripwire_cli/client.py,sha256=-a1bsWTsUZGCeqPpV78oxYBWLlUiovWxeVNi5MZcx4E,5992
|
|
5
|
+
tripwire_cli/credentials.py,sha256=FMH2XUhWku-tu3XdsdRUtWHmB5hu8Lbh4dWR4GtF_Yo,2032
|
|
6
|
+
tripwire_cli-0.2.0.dist-info/METADATA,sha256=HcLl-k5lHNGGNhydcSSs-PQNH3JRek4LZaEg8-2AnKk,2225
|
|
7
|
+
tripwire_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
tripwire_cli-0.2.0.dist-info/entry_points.txt,sha256=9PdcjDdJygDUBO_006xoyJjTRuqb0K1SjNUgSpiw4wI,88
|
|
9
|
+
tripwire_cli-0.2.0.dist-info/RECORD,,
|