tripwire-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/PKG-INFO +6 -2
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/README.md +5 -1
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/pyproject.toml +1 -1
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tests/test_cli.py +244 -8
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tripwire_cli/cli.py +124 -14
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tripwire_cli/client.py +7 -3
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/uv.lock +1 -1
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/.gitignore +0 -0
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tests/test_client.py +0 -0
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tests/test_credentials.py +0 -0
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tripwire_cli/__init__.py +0 -0
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tripwire_cli/__main__.py +0 -0
- {tripwire_cli-0.2.0 → tripwire_cli-0.3.0}/tripwire_cli/credentials.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tripwire-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for Tripwire canaries
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: click>=8.1
|
|
@@ -53,7 +53,11 @@ plain-text messages go to stderr, so stdout stays clean JSON. Run
|
|
|
53
53
|
`tripwire --help` for the full reference.
|
|
54
54
|
|
|
55
55
|
Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
|
|
56
|
-
|
|
56
|
+
`github_pat`, `web_login_credential`, `browser_session_cookie`,
|
|
57
|
+
`postgres_login`, and `kubernetes_kubeconfig`. The request-path types
|
|
58
|
+
(`web_login_credential`, `browser_session_cookie`, `postgres_login`,
|
|
59
|
+
`kubernetes_kubeconfig`) inline their artifact fields directly in the create
|
|
60
|
+
response.
|
|
57
61
|
|
|
58
62
|
`canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
|
|
59
63
|
default 240) for the per-request read timeout; it must stay above the server's
|
|
@@ -44,7 +44,11 @@ plain-text messages go to stderr, so stdout stays clean JSON. Run
|
|
|
44
44
|
`tripwire --help` for the full reference.
|
|
45
45
|
|
|
46
46
|
Supported create types are `dns_label`, `aws_access_key`, `anthropic_api_key`,
|
|
47
|
-
|
|
47
|
+
`github_pat`, `web_login_credential`, `browser_session_cookie`,
|
|
48
|
+
`postgres_login`, and `kubernetes_kubeconfig`. The request-path types
|
|
49
|
+
(`web_login_credential`, `browser_session_cookie`, `postgres_login`,
|
|
50
|
+
`kubernetes_kubeconfig`) inline their artifact fields directly in the create
|
|
51
|
+
response.
|
|
48
52
|
|
|
49
53
|
`canaries create` accepts `--timeout <seconds>` (env `TRIPWIRE_CREATE_TIMEOUT`,
|
|
50
54
|
default 240) for the per-request read timeout; it must stay above the server's
|
|
@@ -14,6 +14,7 @@ from tripwire_cli.cli import (
|
|
|
14
14
|
build_create_payload,
|
|
15
15
|
cli,
|
|
16
16
|
resolve_login_server,
|
|
17
|
+
_unauthorized_message,
|
|
17
18
|
)
|
|
18
19
|
from tripwire_cli.client import ApiError
|
|
19
20
|
|
|
@@ -170,15 +171,18 @@ def test_build_create_payload_matches_api_contract():
|
|
|
170
171
|
assert build_create_payload(canary_type="dns_label") == {"type": "dns_label"}
|
|
171
172
|
|
|
172
173
|
|
|
173
|
-
def
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
# surface them.
|
|
174
|
+
def test_canary_types_are_the_eight_api_types():
|
|
175
|
+
# All eight types POST /canary accepts: the four provider/dns types plus the
|
|
176
|
+
# four request-path types whose artifact fields are inlined in the response.
|
|
177
177
|
assert CANARY_TYPES == [
|
|
178
178
|
"dns_label",
|
|
179
179
|
"aws_access_key",
|
|
180
180
|
"anthropic_api_key",
|
|
181
181
|
"github_pat",
|
|
182
|
+
"web_login_credential",
|
|
183
|
+
"browser_session_cookie",
|
|
184
|
+
"postgres_login",
|
|
185
|
+
"kubernetes_kubeconfig",
|
|
182
186
|
]
|
|
183
187
|
|
|
184
188
|
|
|
@@ -201,10 +205,81 @@ def test_readme_lists_every_supported_create_type(path: Path):
|
|
|
201
205
|
"kubernetes_kubeconfig",
|
|
202
206
|
],
|
|
203
207
|
)
|
|
204
|
-
def
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
def test_create_accepts_request_path_types(tmp_path, canary_type: str):
|
|
209
|
+
# The four request-path types are now part of the public --type surface and
|
|
210
|
+
# send the same {"type": ...} body as the provider types.
|
|
211
|
+
canary = {"id": "can_1", "type": canary_type, "status": "active"}
|
|
212
|
+
client = _FakeClient(result=canary)
|
|
213
|
+
result = CliRunner().invoke(
|
|
214
|
+
cli,
|
|
215
|
+
["canaries", "create", "--type", canary_type],
|
|
216
|
+
obj=_logged_in_context(tmp_path, client),
|
|
217
|
+
)
|
|
218
|
+
assert result.exit_code == 0, result.output
|
|
219
|
+
assert client.created_payloads == [{"type": canary_type}]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize(
|
|
223
|
+
"canary_type,inlined",
|
|
224
|
+
[
|
|
225
|
+
(
|
|
226
|
+
"web_login_credential",
|
|
227
|
+
{
|
|
228
|
+
"url": "https://app.example/login",
|
|
229
|
+
"username": "svc-backups",
|
|
230
|
+
"password": "s3kret",
|
|
231
|
+
},
|
|
232
|
+
),
|
|
233
|
+
(
|
|
234
|
+
"browser_session_cookie",
|
|
235
|
+
{
|
|
236
|
+
"url": "https://app.example",
|
|
237
|
+
"cookie_name": "session",
|
|
238
|
+
"cookie_value": "abc.def",
|
|
239
|
+
"cookie_domain": "app.example",
|
|
240
|
+
"cookie_path": "/",
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
(
|
|
244
|
+
"postgres_login",
|
|
245
|
+
{
|
|
246
|
+
"database_url": "postgres://u:p@h:5432/db?sslmode=require",
|
|
247
|
+
"host": "h",
|
|
248
|
+
"port": 5432,
|
|
249
|
+
"database": "db",
|
|
250
|
+
"username": "u",
|
|
251
|
+
"password": "p",
|
|
252
|
+
"sslmode": "require",
|
|
253
|
+
"url": "postgres://h:5432",
|
|
254
|
+
},
|
|
255
|
+
),
|
|
256
|
+
(
|
|
257
|
+
"kubernetes_kubeconfig",
|
|
258
|
+
{
|
|
259
|
+
"kubeconfig": "apiVersion: v1\nkind: Config\n",
|
|
260
|
+
"server": "https://k8s.example:6443",
|
|
261
|
+
"cluster_name": "prod",
|
|
262
|
+
"user_name": "deployer",
|
|
263
|
+
"bearer_token": "eyJ.tok",
|
|
264
|
+
"token": "eyJ.tok",
|
|
265
|
+
},
|
|
266
|
+
),
|
|
267
|
+
],
|
|
268
|
+
)
|
|
269
|
+
def test_create_prints_request_path_inlined_fields_verbatim(
|
|
270
|
+
tmp_path, canary_type: str, inlined: dict
|
|
271
|
+
):
|
|
272
|
+
# The create response inlines each type's artifact fields; the CLI prints
|
|
273
|
+
# the server JSON unchanged and must not filter any field out.
|
|
274
|
+
canary = {"id": "can_1", "type": canary_type, "status": "active", **inlined}
|
|
275
|
+
client = _FakeClient(result=canary)
|
|
276
|
+
result = CliRunner().invoke(
|
|
277
|
+
cli,
|
|
278
|
+
["canaries", "create", "--type", canary_type],
|
|
279
|
+
obj=_logged_in_context(tmp_path, client),
|
|
280
|
+
)
|
|
281
|
+
assert result.exit_code == 0, result.output
|
|
282
|
+
assert json.loads(result.stdout) == canary
|
|
208
283
|
|
|
209
284
|
|
|
210
285
|
# --- argument surface -------------------------------------------------------
|
|
@@ -590,3 +665,164 @@ def test_whoami_prints_email_when_present(tmp_path):
|
|
|
590
665
|
result = CliRunner().invoke(cli, ["whoami"], obj=ctx)
|
|
591
666
|
assert result.exit_code == 0
|
|
592
667
|
assert "alice@example.com" in result.output
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# --- login resilience: non-interactive flags --------------------------------
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class _StartErrorClient(_FakeClient):
|
|
674
|
+
"""Email-login fake whose /auth/login/start raises a canned ApiError, so a
|
|
675
|
+
test can drive the rate-limit (429) path."""
|
|
676
|
+
|
|
677
|
+
def __init__(self, *, start_error: ApiError):
|
|
678
|
+
super().__init__(result=None)
|
|
679
|
+
self._start_error = start_error
|
|
680
|
+
|
|
681
|
+
def login_start(self, email):
|
|
682
|
+
self.login_starts.append(email)
|
|
683
|
+
raise self._start_error
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class _CodeErrorClient(_FakeClient):
|
|
687
|
+
"""Email-login fake whose /auth/login (code exchange) raises a canned
|
|
688
|
+
ApiError, so a test can drive the server-error (5xx) path. start() succeeds
|
|
689
|
+
so the test reaches the exchange."""
|
|
690
|
+
|
|
691
|
+
def __init__(self, *, code_error: ApiError):
|
|
692
|
+
super().__init__(result=None)
|
|
693
|
+
self._code_error = code_error
|
|
694
|
+
|
|
695
|
+
def login_with_code(self, email, code):
|
|
696
|
+
self.code_logins.append((email, code))
|
|
697
|
+
raise self._code_error
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
_GOOD_LOGIN = {
|
|
701
|
+
"user_id": "usr_alice",
|
|
702
|
+
"access_token": "tok",
|
|
703
|
+
"expires_at": 1700000000,
|
|
704
|
+
"role": "user",
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def test_login_email_and_code_flags_skip_prompts_and_start(tmp_path, monkeypatch):
|
|
709
|
+
# Both --email and --code given: a fully non-interactive code exchange that
|
|
710
|
+
# does NOT call the rate-limited start and never prompts.
|
|
711
|
+
monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
|
|
712
|
+
client = _FakeClient(result=_GOOD_LOGIN)
|
|
713
|
+
ctx = _context(tmp_path, client)
|
|
714
|
+
result = CliRunner().invoke(
|
|
715
|
+
cli,
|
|
716
|
+
["login", "--email", "ci@example.com", "--code", "123456"],
|
|
717
|
+
input="", # no TTY input available
|
|
718
|
+
obj=ctx,
|
|
719
|
+
)
|
|
720
|
+
assert result.exit_code == 0, result.output
|
|
721
|
+
assert client.login_starts == [] # start is rate-limited; must be skipped
|
|
722
|
+
assert client.code_logins == [("ci@example.com", "123456")]
|
|
723
|
+
loaded = ctx.store.load()
|
|
724
|
+
assert loaded.access_token == "tok"
|
|
725
|
+
assert loaded.email == "ci@example.com"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def test_login_email_flag_skips_email_prompt_only(tmp_path, monkeypatch):
|
|
729
|
+
# --email without --code: start is sent (no code yet) and only the code is
|
|
730
|
+
# prompted for.
|
|
731
|
+
monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
|
|
732
|
+
client = _FakeClient(result=_GOOD_LOGIN)
|
|
733
|
+
ctx = _context(tmp_path, client)
|
|
734
|
+
result = CliRunner().invoke(
|
|
735
|
+
cli, ["login", "--email", "ci@example.com"], input="123456\n", obj=ctx
|
|
736
|
+
)
|
|
737
|
+
assert result.exit_code == 0, result.output
|
|
738
|
+
assert client.login_starts == ["ci@example.com"]
|
|
739
|
+
assert client.code_logins == [("ci@example.com", "123456")]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# --- login resilience: start rate limit (429) -------------------------------
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_login_start_rate_limit_gives_friendly_message(tmp_path, monkeypatch):
|
|
746
|
+
monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
|
|
747
|
+
client = _StartErrorClient(start_error=ApiError(429, "rate_limited"))
|
|
748
|
+
ctx = _context(tmp_path, client)
|
|
749
|
+
result = CliRunner().invoke(cli, ["login"], input="alice@example.com\n", obj=ctx)
|
|
750
|
+
assert result.exit_code != 0
|
|
751
|
+
# Friendly, actionable text rather than a raw "429: rate_limited".
|
|
752
|
+
assert "too many login attempts from this network" in result.output
|
|
753
|
+
assert "~10 minutes" in result.output
|
|
754
|
+
assert "429: rate_limited" not in result.output
|
|
755
|
+
# No token cached on a failed login.
|
|
756
|
+
assert not (tmp_path / "credentials.json").exists()
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def test_login_start_non_429_error_is_not_masked(tmp_path, monkeypatch):
|
|
760
|
+
# A non-rate-limit start failure keeps the generic API-error surface.
|
|
761
|
+
monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
|
|
762
|
+
client = _StartErrorClient(start_error=ApiError(503, "upstream_down"))
|
|
763
|
+
ctx = _context(tmp_path, client)
|
|
764
|
+
result = CliRunner().invoke(cli, ["login"], input="alice@example.com\n", obj=ctx)
|
|
765
|
+
assert result.exit_code != 0
|
|
766
|
+
assert "503: upstream_down" in result.output
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# --- login resilience: 5xx on the code exchange -----------------------------
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@pytest.mark.parametrize("status", [500, 502, 503])
|
|
773
|
+
def test_login_code_exchange_5xx_tells_user_code_may_be_spent(
|
|
774
|
+
tmp_path, monkeypatch, status
|
|
775
|
+
):
|
|
776
|
+
# A 5xx during the code exchange may have consumed the code server-side;
|
|
777
|
+
# retrying the same code is futile, so the CLI exits cleanly and tells the
|
|
778
|
+
# user to re-run login for a fresh code.
|
|
779
|
+
monkeypatch.setenv("TRIPWIRE_SERVER", "https://api.example")
|
|
780
|
+
client = _CodeErrorClient(code_error=ApiError(status, "internal_error"))
|
|
781
|
+
ctx = _context(tmp_path, client)
|
|
782
|
+
result = CliRunner().invoke(
|
|
783
|
+
cli, ["login"], input="alice@example.com\n123456\n", obj=ctx
|
|
784
|
+
)
|
|
785
|
+
assert result.exit_code != 0
|
|
786
|
+
# start() was called exactly once; the code was attempted exactly once (no
|
|
787
|
+
# silent retry of a possibly-spent code).
|
|
788
|
+
assert client.login_starts == ["alice@example.com"]
|
|
789
|
+
assert client.code_logins == [("alice@example.com", "123456")]
|
|
790
|
+
assert "may already be spent" in result.output
|
|
791
|
+
assert "run `tripwire login` again" in result.output
|
|
792
|
+
assert not (tmp_path / "credentials.json").exists()
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# --- login resilience: 401 detail polish ------------------------------------
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@pytest.mark.parametrize(
|
|
799
|
+
"raw_detail",
|
|
800
|
+
[
|
|
801
|
+
"Invalid header padding",
|
|
802
|
+
"Invalid token",
|
|
803
|
+
"Not enough segments",
|
|
804
|
+
"Signature verification failed",
|
|
805
|
+
"token expired",
|
|
806
|
+
"Failed to decrypt token",
|
|
807
|
+
],
|
|
808
|
+
)
|
|
809
|
+
def test_unauthorized_message_maps_token_errors_to_session_expired(raw_detail):
|
|
810
|
+
assert _unauthorized_message(raw_detail) == "session expired; run `tripwire login`"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_unauthorized_message_keeps_meaningful_detail():
|
|
814
|
+
assert _unauthorized_message("forbidden_scope") == (
|
|
815
|
+
"401: forbidden_scope\nhint: run `tripwire login`"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def test_list_maps_opaque_401_to_session_expired(tmp_path):
|
|
820
|
+
# A 401 whose detail is a raw token-decode error becomes a clean
|
|
821
|
+
# session-expired prompt instead of leaking "Invalid header padding".
|
|
822
|
+
client = _FakeClient(error=ApiError(401, "Invalid header padding"))
|
|
823
|
+
result = CliRunner().invoke(
|
|
824
|
+
cli, ["canaries", "list"], obj=_logged_in_context(tmp_path, client)
|
|
825
|
+
)
|
|
826
|
+
assert result.exit_code != 0
|
|
827
|
+
assert "session expired; run `tripwire login`" in result.output
|
|
828
|
+
assert "Invalid header padding" not in result.output
|
|
@@ -50,7 +50,22 @@ def _git_user_email() -> str | None:
|
|
|
50
50
|
|
|
51
51
|
DEFAULT_SERVER = "https://tripwire.so/api/v1"
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# The canary types the API's POST /canary accepts. The provider-minted types
|
|
54
|
+
# (aws/anthropic/github) take ~2 min to provision; the request-path types
|
|
55
|
+
# (web_login_credential, browser_session_cookie, postgres_login,
|
|
56
|
+
# kubernetes_kubeconfig) inline their artifact fields directly in the create
|
|
57
|
+
# response. The CLI prints the server JSON verbatim, so new inlined fields flow
|
|
58
|
+
# through unchanged.
|
|
59
|
+
CANARY_TYPES = [
|
|
60
|
+
"dns_label",
|
|
61
|
+
"aws_access_key",
|
|
62
|
+
"anthropic_api_key",
|
|
63
|
+
"github_pat",
|
|
64
|
+
"web_login_credential",
|
|
65
|
+
"browser_session_cookie",
|
|
66
|
+
"postgres_login",
|
|
67
|
+
"kubernetes_kubeconfig",
|
|
68
|
+
]
|
|
54
69
|
|
|
55
70
|
# How long the create read timeout may run, in seconds. Must stay above the
|
|
56
71
|
# server's synchronous create wait window (180s) so the client never abandons a
|
|
@@ -113,6 +128,29 @@ class Context:
|
|
|
113
128
|
return None
|
|
114
129
|
|
|
115
130
|
|
|
131
|
+
# Substrings the server (or its JWT/Fernet token layer) leaks on a 401 when the
|
|
132
|
+
# cached token is malformed or undecodable. These are opaque to users, so we map
|
|
133
|
+
# them to a plain "session expired" message instead of echoing the raw detail.
|
|
134
|
+
_EXPIRED_SESSION_TOKEN_MARKERS = (
|
|
135
|
+
"invalid header padding",
|
|
136
|
+
"invalid token",
|
|
137
|
+
"not enough segments",
|
|
138
|
+
"signature",
|
|
139
|
+
"expired",
|
|
140
|
+
"decrypt",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unauthorized_message(detail: str) -> str:
|
|
145
|
+
"""User-facing text for a 401. Raw token/header decode errors (e.g. "Invalid
|
|
146
|
+
header padding") are meaningless to users, so map them to a clear
|
|
147
|
+
session-expired prompt; otherwise keep the server detail and append a hint."""
|
|
148
|
+
lowered = detail.lower()
|
|
149
|
+
if any(marker in lowered for marker in _EXPIRED_SESSION_TOKEN_MARKERS):
|
|
150
|
+
return "session expired; run `tripwire login`"
|
|
151
|
+
return f"401: {detail}\nhint: run `tripwire login`"
|
|
152
|
+
|
|
153
|
+
|
|
116
154
|
def _handle_errors(func):
|
|
117
155
|
"""Translate API/credential errors into a clean CLI error and exit code."""
|
|
118
156
|
|
|
@@ -121,10 +159,9 @@ def _handle_errors(func):
|
|
|
121
159
|
try:
|
|
122
160
|
return func(*args, **kwargs)
|
|
123
161
|
except ApiError as exc:
|
|
124
|
-
message = f"{exc.status}: {exc.detail}"
|
|
125
162
|
if exc.status == 401:
|
|
126
|
-
|
|
127
|
-
raise click.ClickException(
|
|
163
|
+
raise click.ClickException(_unauthorized_message(exc.detail)) from exc
|
|
164
|
+
raise click.ClickException(f"{exc.status}: {exc.detail}") from exc
|
|
128
165
|
except (credentials.NoCredentialsError, ValueError) as exc:
|
|
129
166
|
raise click.ClickException(str(exc)) from exc
|
|
130
167
|
|
|
@@ -148,21 +185,42 @@ def cli(ctx: click.Context) -> None:
|
|
|
148
185
|
@click.option(
|
|
149
186
|
"--password", help="operator password (selects password login; prefer the prompt)"
|
|
150
187
|
)
|
|
188
|
+
@click.option(
|
|
189
|
+
"--email",
|
|
190
|
+
help="email for passwordless login (skips the prompt; for headless/CI use)",
|
|
191
|
+
)
|
|
192
|
+
@click.option(
|
|
193
|
+
"--code",
|
|
194
|
+
help=(
|
|
195
|
+
"6-digit code for passwordless login (skips the code prompt; pair with "
|
|
196
|
+
"--email for a non-interactive exchange of an already-sent code)"
|
|
197
|
+
),
|
|
198
|
+
)
|
|
151
199
|
@click.pass_obj
|
|
152
200
|
@_handle_errors
|
|
153
|
-
def login(
|
|
201
|
+
def login(
|
|
202
|
+
obj: Context,
|
|
203
|
+
user_id: str | None,
|
|
204
|
+
password: str | None,
|
|
205
|
+
email: str | None,
|
|
206
|
+
code: str | None,
|
|
207
|
+
) -> None:
|
|
154
208
|
"""Log in and cache a token.
|
|
155
209
|
|
|
156
210
|
Defaults to passwordless email-code login. Passing --user-id or --password
|
|
157
211
|
selects the operator (user-id + password) login instead, so operator
|
|
158
212
|
scripts keep working unchanged.
|
|
213
|
+
|
|
214
|
+
For headless/CI use, pass --email (and optionally --code) to skip the
|
|
215
|
+
interactive prompts. With both --email and --code, a code that was already
|
|
216
|
+
emailed is exchanged directly without re-sending one or prompting.
|
|
159
217
|
"""
|
|
160
218
|
server = resolve_login_server(dict(os.environ), obj.cached_server())
|
|
161
219
|
client = obj.client(server)
|
|
162
220
|
if user_id is not None or password is not None:
|
|
163
221
|
creds = _password_login(client, server, user_id, password)
|
|
164
222
|
else:
|
|
165
|
-
creds = _email_login(client, server, obj.git_email())
|
|
223
|
+
creds = _email_login(client, server, obj.git_email(), email=email, code=code)
|
|
166
224
|
path = obj.store.save(creds)
|
|
167
225
|
click.echo(f"logged in as {creds.user_id} ({creds.role}); token cached at {path}")
|
|
168
226
|
|
|
@@ -178,19 +236,40 @@ def _password_login(
|
|
|
178
236
|
|
|
179
237
|
|
|
180
238
|
def _email_login(
|
|
181
|
-
client: ApiClient,
|
|
239
|
+
client: ApiClient,
|
|
240
|
+
server: str,
|
|
241
|
+
default_email: str | None,
|
|
242
|
+
*,
|
|
243
|
+
email: str | None = None,
|
|
244
|
+
code: str | None = None,
|
|
182
245
|
) -> credentials.Credentials:
|
|
183
|
-
"""Passwordless email-code login.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
"""Passwordless email-code login.
|
|
247
|
+
|
|
248
|
+
Interactive path (no ``code``): calls /auth/login/start once (it is
|
|
249
|
+
rate-limited), then prompts for the 6-digit code, re-prompting in-band on an
|
|
250
|
+
invalid/expired code without re-calling start.
|
|
251
|
+
|
|
252
|
+
Non-interactive path (``code`` supplied): exchanges that code directly and
|
|
253
|
+
does NOT call /auth/login/start, since the code was already emailed and
|
|
254
|
+
re-sending would burn the rate-limited start and invalidate the held code.
|
|
255
|
+
|
|
256
|
+
``email`` (e.g. from ``--email``) skips the email prompt for headless/CI use.
|
|
257
|
+
"""
|
|
258
|
+
email = email or click.prompt("email", default=default_email)
|
|
259
|
+
|
|
260
|
+
if code is not None:
|
|
261
|
+
# Headless: a code was supplied, so do not (re)send one. Exchange it
|
|
262
|
+
# directly; a single attempt, no prompt loop.
|
|
263
|
+
response = _exchange_code(client, email, code)
|
|
264
|
+
return _credentials_from_login(server, response, email=email)
|
|
265
|
+
|
|
266
|
+
_start_email_login(client, email)
|
|
188
267
|
click.echo(f"sent a 6-digit sign-in code to {email}; check your inbox.")
|
|
189
268
|
last_error: ApiError | None = None
|
|
190
269
|
for attempt in range(EMAIL_CODE_ATTEMPTS):
|
|
191
|
-
|
|
270
|
+
entered = click.prompt("code")
|
|
192
271
|
try:
|
|
193
|
-
response = client
|
|
272
|
+
response = _exchange_code(client, email, entered)
|
|
194
273
|
except ApiError as exc:
|
|
195
274
|
if exc.status == 400 and exc.detail == "invalid_or_expired_code":
|
|
196
275
|
last_error = exc
|
|
@@ -207,6 +286,37 @@ def _email_login(
|
|
|
207
286
|
raise last_error if last_error is not None else ApiError(400, "invalid_or_expired_code")
|
|
208
287
|
|
|
209
288
|
|
|
289
|
+
def _start_email_login(client: ApiClient, email: str) -> None:
|
|
290
|
+
"""Send the sign-in code, turning the rate-limit 429 into a friendly,
|
|
291
|
+
actionable message instead of a raw ``429: rate_limited``."""
|
|
292
|
+
try:
|
|
293
|
+
client.login_start(email)
|
|
294
|
+
except ApiError as exc:
|
|
295
|
+
if exc.status == 429:
|
|
296
|
+
raise click.ClickException(
|
|
297
|
+
"too many login attempts from this network; wait ~10 minutes and "
|
|
298
|
+
"try `tripwire login` again."
|
|
299
|
+
) from exc
|
|
300
|
+
raise
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _exchange_code(client: ApiClient, email: str, code: str) -> dict[str, Any]:
|
|
304
|
+
"""Exchange a code for a token. A server-side 5xx here is the dangerous case:
|
|
305
|
+
the code may already have been consumed, so retrying the same code is futile
|
|
306
|
+
and silently re-sending one would burn the rate-limited start. Surface a
|
|
307
|
+
clear message and have the user re-run `tripwire login` for a fresh code."""
|
|
308
|
+
try:
|
|
309
|
+
return client.login_with_code(email, code)
|
|
310
|
+
except ApiError as exc:
|
|
311
|
+
if exc.status >= 500:
|
|
312
|
+
raise click.ClickException(
|
|
313
|
+
"the server errored while verifying your code "
|
|
314
|
+
f"({exc.status}: {exc.detail}); your code may already be spent. "
|
|
315
|
+
"run `tripwire login` again to request a fresh code."
|
|
316
|
+
) from exc
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
|
|
210
320
|
def _credentials_from_login(
|
|
211
321
|
server: str, response: dict[str, Any], email: str | None = None
|
|
212
322
|
) -> credentials.Credentials:
|
|
@@ -111,13 +111,17 @@ class ApiClient:
|
|
|
111
111
|
def login_start(self, email: str) -> dict[str, Any]:
|
|
112
112
|
"""Begin an email-code login: the server emails a 6-digit code. The
|
|
113
113
|
response is intentionally neutral (``{"status": "ok"}``) and never
|
|
114
|
-
reveals whether the address is known. This endpoint is rate-limited
|
|
115
|
-
|
|
114
|
+
reveals whether the address is known. This endpoint is IP rate-limited
|
|
115
|
+
(~5 starts / 10 min, plus a new-user cap), returning
|
|
116
|
+
``429 rate_limited`` when exceeded, so call it once per login and
|
|
117
|
+
re-prompt for the code in-band on failure."""
|
|
116
118
|
return self._request("POST", "/auth/login/start", {"email": email})
|
|
117
119
|
|
|
118
120
|
def login_with_code(self, email: str, code: str) -> dict[str, Any]:
|
|
119
121
|
"""Exchange an emailed 6-digit code for a token. A wrong/expired/used
|
|
120
|
-
code returns ``400 invalid_or_expired_code``.
|
|
122
|
+
code returns ``400 invalid_or_expired_code``. A ``5xx`` here can leave
|
|
123
|
+
the code consumed server-side, so the caller treats it as spent and
|
|
124
|
+
sends the user back to request a fresh code rather than retrying."""
|
|
121
125
|
return self._request("POST", "/auth/login", {"email": email, "code": code})
|
|
122
126
|
|
|
123
127
|
def list_canaries(self) -> dict[str, Any]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|