Skip to content

Commit 3249bda

Browse files
author
smkc
committed
feat(cli): add token-store policy visibility and plaintext warnings
1 parent 70b40d6 commit 3249bda

File tree

6 files changed

+153
-2
lines changed

6 files changed

+153
-2
lines changed

docs/cli/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,14 @@ KSEF_CLI_TOKEN_STORE_KEY=<TWOJ_KLUCZ>
454454
KSEF_CLI_ALLOW_INSECURE_TOKEN_STORE=1
455455
```
456456

457+
- Gdy CLI faktycznie uzyje plaintext fallback, wypisuje jawne ostrzezenie o niezabezpieczonym zapisie tokenow.
457458
- Na Windows plaintext fallback jest zablokowany nawet po ustawieniu tej zmiennej; uzyj keyringa albo fallbacku szyfrowanego.
458459
- Gdy keyring jest obecny, ale backend zwraca blad, CLI automatycznie przechodzi na dostepny fallback (`KSEF_CLI_TOKEN_STORE_KEY` lub awaryjny plaintext poza Windows).
460+
- `ksef health check` oraz diagnostyka preflight pokazuja aktualny tryb polityki token-store jako jedno z:
461+
- `keyring`
462+
- `encrypted-fallback`
463+
- `plaintext-fallback`
464+
- `unavailable`
459465

460466
Lokalizacja token fallback:
461467
- Windows: `%LOCALAPPDATA%/ksef-cli/tokens.json`

src/ksef_client/cli/auth/keyring_store.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import time
99
import uuid
10+
import warnings
1011
from collections.abc import Iterator
1112
from contextlib import contextmanager, suppress
1213
from pathlib import Path
@@ -54,6 +55,10 @@ class _KeyringError(Exception):
5455
_ALLOW_INSECURE_FALLBACK_ENV = "KSEF_CLI_ALLOW_INSECURE_TOKEN_STORE"
5556
_TOKEN_STORE_KEY_ENV = "KSEF_CLI_TOKEN_STORE_KEY"
5657
_ENCRYPTION_MODE = "fernet-v1"
58+
_PLAINTEXT_FALLBACK_WARNING = (
59+
"Using plaintext token fallback storage. Tokens are stored unencrypted in tokens.json."
60+
)
61+
_PLAINTEXT_WARNING_EMITTED = False
5762

5863

5964
class _KeyringBackend(Protocol):
@@ -106,6 +111,24 @@ def _fallback_mode() -> str | None:
106111
return None
107112

108113

114+
def get_token_store_mode() -> str:
115+
if _KEYRING_AVAILABLE and _keyring is not None:
116+
return "keyring"
117+
if _encrypted_fallback_enabled():
118+
return "encrypted-fallback"
119+
if _plaintext_fallback_allowed():
120+
return "plaintext-fallback"
121+
return "unavailable"
122+
123+
124+
def _warn_plaintext_fallback_used(mode: str | None) -> None:
125+
global _PLAINTEXT_WARNING_EMITTED
126+
if mode != "insecure-plaintext" or _PLAINTEXT_WARNING_EMITTED:
127+
return
128+
warnings.warn(_PLAINTEXT_FALLBACK_WARNING, UserWarning, stacklevel=3)
129+
_PLAINTEXT_WARNING_EMITTED = True
130+
131+
109132
def _fallback_cipher() -> Any:
110133
key = _token_store_key()
111134
if key is None or _FernetClass is None:
@@ -271,6 +294,7 @@ def save_tokens(profile: str, access_token: str, refresh_token: str) -> None:
271294
mode = _fallback_mode()
272295
if mode is None:
273296
_raise_no_secure_store()
297+
_warn_plaintext_fallback_used(mode)
274298
encoded_tokens = _encode_tokens(
275299
access_token=access_token, refresh_token=refresh_token, mode=mode
276300
)
@@ -315,7 +339,9 @@ def get_tokens(profile: str) -> tuple[str, str] | None:
315339
if _fallback_mode() is None:
316340
return None
317341

318-
if _fallback_mode() is not None:
342+
mode = _fallback_mode()
343+
if mode is not None:
344+
_warn_plaintext_fallback_used(mode)
319345
payload = _load_fallback_tokens()
320346
profile_data = payload.get(profile)
321347
if not isinstance(profile_data, dict):

src/ksef_client/cli/commands/health_cmd.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ksef_client.exceptions import KsefApiError, KsefHttpError, KsefRateLimitError
88

9+
from ..auth.keyring_store import get_token_store_mode
910
from ..auth.manager import resolve_base_url
1011
from ..context import profile_label, require_context, require_profile
1112
from ..errors import CliError
@@ -16,6 +17,27 @@
1617
app = typer.Typer(help="Run connectivity and diagnostics checks.")
1718

1819

20+
def _append_token_store_check(result: dict[str, object]) -> None:
21+
checks_obj = result.get("checks")
22+
if not isinstance(checks_obj, list):
23+
checks_obj = []
24+
result["checks"] = checks_obj
25+
26+
checks = checks_obj
27+
for item in checks:
28+
if isinstance(item, dict) and item.get("name") == "token_store":
29+
return
30+
31+
mode = get_token_store_mode()
32+
checks.append(
33+
{
34+
"name": "token_store",
35+
"status": "WARN" if mode in {"plaintext-fallback", "unavailable"} else "PASS",
36+
"message": mode,
37+
}
38+
)
39+
40+
1941
def _render_error(ctx: typer.Context, command: str, exc: Exception) -> None:
2042
cli_ctx = require_context(ctx)
2143
renderer = get_renderer(cli_ctx)
@@ -89,6 +111,7 @@ def health_check(
89111
check_auth=check_auth,
90112
check_certs=check_certs,
91113
)
114+
_append_token_store_check(result)
92115
except Exception as exc:
93116
_render_error(ctx, "health.check", exc)
94117
renderer.success(command="health.check", profile=profile, data=result)

src/ksef_client/cli/diagnostics/checks.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any
44

5-
from ..auth.keyring_store import get_tokens
5+
from ..auth.keyring_store import get_token_store_mode, get_tokens
66
from ..config.loader import load_config
77

88

@@ -19,6 +19,7 @@ def run_preflight(profile: str | None = None) -> dict[str, Any]:
1919
context_type = selected_cfg.context_type if selected_cfg else ""
2020
context_value = selected_cfg.context_value if selected_cfg else ""
2121
has_tokens = bool(get_tokens(selected_profile)) if selected_profile else False
22+
token_store_mode = get_token_store_mode()
2223

2324
if selected_profile is None:
2425
profile_status = "WARN"
@@ -75,6 +76,11 @@ def run_preflight(profile: str | None = None) -> dict[str, Any]:
7576
)
7677
),
7778
},
79+
{
80+
"name": "token_store",
81+
"status": "PASS",
82+
"message": token_store_mode,
83+
},
7884
]
7985

8086
overall = "PASS" if all(item["status"] == "PASS" for item in checks) else "WARN"

tests/cli/integration/test_health_check.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,38 @@ def test_health_check_error_exit(runner, monkeypatch) -> None:
4646
)
4747
result = runner.invoke(app, ["health", "check", "--check-auth"])
4848
assert result.exit_code == int(ExitCode.AUTH_ERROR)
49+
50+
51+
def test_health_check_json_includes_token_store_mode(runner, monkeypatch) -> None:
52+
monkeypatch.setattr(
53+
health_cmd,
54+
"run_health_check",
55+
lambda **kwargs: {"overall": "PASS", "checks": [{"name": "base_url", "status": "PASS"}]},
56+
)
57+
monkeypatch.setattr(health_cmd, "get_token_store_mode", lambda: "plaintext-fallback")
58+
59+
result = runner.invoke(app, ["--json", "health", "check"])
60+
assert result.exit_code == 0
61+
payload = _json_output(result.stdout)
62+
token_store_checks = [c for c in payload["data"]["checks"] if c["name"] == "token_store"]
63+
assert token_store_checks == [
64+
{"name": "token_store", "status": "WARN", "message": "plaintext-fallback"}
65+
]
66+
67+
68+
def test_health_check_does_not_duplicate_existing_token_store_check(runner, monkeypatch) -> None:
69+
monkeypatch.setattr(
70+
health_cmd,
71+
"run_health_check",
72+
lambda **kwargs: {
73+
"overall": "PASS",
74+
"checks": [{"name": "token_store", "status": "PASS", "message": "keyring"}],
75+
},
76+
)
77+
monkeypatch.setattr(health_cmd, "get_token_store_mode", lambda: "plaintext-fallback")
78+
79+
result = runner.invoke(app, ["--json", "health", "check"])
80+
assert result.exit_code == 0
81+
payload = _json_output(result.stdout)
82+
token_store_checks = [c for c in payload["data"]["checks"] if c["name"] == "token_store"]
83+
assert token_store_checks == [{"name": "token_store", "status": "PASS", "message": "keyring"}]

tests/cli/unit/test_keyring_store.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import threading
77
import time
88
import types
9+
import warnings
910

1011
import pytest
1112

@@ -471,3 +472,57 @@ def delete_password(self, service: str, key: str) -> None:
471472
assert reloaded._KEYRING_AVAILABLE is True
472473
finally:
473474
importlib.reload(original_module)
475+
476+
477+
@pytest.mark.parametrize(
478+
("keyring_available", "keyring_backend", "store_key", "allow_insecure", "os_name", "expected"),
479+
[
480+
(True, object(), "", "", "posix", "keyring"),
481+
(False, None, "my-secret-passphrase", "", "posix", "encrypted-fallback"),
482+
(False, None, "", "1", "posix", "plaintext-fallback"),
483+
(False, None, "", "1", "nt", "unavailable"),
484+
(False, None, "", "", "posix", "unavailable"),
485+
],
486+
)
487+
def test_keyring_store_mode_detection(
488+
monkeypatch,
489+
keyring_available: bool,
490+
keyring_backend: object | None,
491+
store_key: str,
492+
allow_insecure: str,
493+
os_name: str,
494+
expected: str,
495+
) -> None:
496+
monkeypatch.setattr(keyring_store, "_KEYRING_AVAILABLE", keyring_available)
497+
monkeypatch.setattr(keyring_store, "_keyring", keyring_backend)
498+
monkeypatch.setattr(keyring_store.os, "name", os_name, raising=False)
499+
500+
if store_key:
501+
monkeypatch.setenv(keyring_store._TOKEN_STORE_KEY_ENV, store_key)
502+
else:
503+
monkeypatch.delenv(keyring_store._TOKEN_STORE_KEY_ENV, raising=False)
504+
505+
if allow_insecure:
506+
monkeypatch.setenv(keyring_store._ALLOW_INSECURE_FALLBACK_ENV, allow_insecure)
507+
else:
508+
monkeypatch.delenv(keyring_store._ALLOW_INSECURE_FALLBACK_ENV, raising=False)
509+
510+
assert keyring_store.get_token_store_mode() == expected
511+
512+
513+
def test_keyring_store_plaintext_fallback_warns_once_on_use(monkeypatch, tmp_path) -> None:
514+
monkeypatch.setattr(keyring_store, "_KEYRING_AVAILABLE", False)
515+
monkeypatch.setattr(keyring_store, "_keyring", None)
516+
monkeypatch.setattr(keyring_store, "cache_dir", lambda: tmp_path)
517+
monkeypatch.setattr(keyring_store.os, "name", "posix", raising=False)
518+
monkeypatch.setenv(keyring_store._ALLOW_INSECURE_FALLBACK_ENV, "1")
519+
monkeypatch.delenv(keyring_store._TOKEN_STORE_KEY_ENV, raising=False)
520+
monkeypatch.setattr(keyring_store, "_PLAINTEXT_WARNING_EMITTED", False)
521+
522+
with pytest.warns(UserWarning, match="plaintext token fallback"):
523+
keyring_store.save_tokens("demo", "acc", "ref")
524+
525+
with warnings.catch_warnings(record=True) as recorded:
526+
warnings.simplefilter("always")
527+
keyring_store.get_tokens("demo")
528+
assert len(recorded) == 0

0 commit comments

Comments
 (0)