Skip to content

Commit d53adee

Browse files
authored
fix: redact calendar access tokens in logs
Replace raw calendar access tokens in error logs with a short fingerprint and add regression coverage for token leak paths.
1 parent 153a6ba commit d53adee

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

envs/calendar_env/server/database/managers/user_manager.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
User Manager - Database operations for user management using SQLAlchemy
33
"""
44

5+
import hashlib
56
import logging
67
import uuid
78
from datetime import datetime, timezone
@@ -14,6 +15,13 @@
1415
logger = logging.getLogger(__name__)
1516

1617

18+
def _token_fingerprint(token: Optional[str]) -> str:
19+
"""Return a short, non-reversible fingerprint of an access token"""
20+
if not token:
21+
return "<empty>"
22+
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
23+
24+
1725
class UserManager:
1826
"""Manager for user database operations using SQLAlchemy"""
1927

@@ -112,7 +120,12 @@ def get_user_by_access_token(self, static_token: str) -> Optional[Dict]:
112120
return self._format_user_response(user)
113121

114122
except Exception as e:
115-
logger.error(f"Error getting user by access token {static_token}: {e}")
123+
# Never log the raw token; emit only a short fingerprint so we
124+
# can correlate failures without leaking credentials to the log sink.
125+
logger.error(
126+
f"Error getting user by access token "
127+
f"(fingerprint={_token_fingerprint(static_token)}): {type(e).__name__}"
128+
)
116129
raise
117130
finally:
118131
session.close()
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Tests that calendar_env UserManager does not leak access tokens to logs."""
2+
3+
import hashlib
4+
import importlib.util
5+
import logging
6+
import sys
7+
from pathlib import Path
8+
from unittest.mock import MagicMock
9+
10+
import pytest
11+
12+
REPO_ROOT = Path(__file__).resolve().parents[2]
13+
CALENDAR_SERVER = REPO_ROOT / "envs" / "calendar_env" / "server"
14+
USER_MANAGER_PATH = CALENDAR_SERVER / "database" / "managers" / "user_manager.py"
15+
16+
17+
def _load_user_manager_module():
18+
"""Import user_manager.py directly"""
19+
# Make `from database.x import ...` inside user_manager.py resolve.
20+
server_path = str(CALENDAR_SERVER)
21+
if server_path not in sys.path:
22+
sys.path.insert(0, server_path)
23+
24+
spec = importlib.util.spec_from_file_location(
25+
"calendar_env_user_manager", str(USER_MANAGER_PATH)
26+
)
27+
module = importlib.util.module_from_spec(spec)
28+
spec.loader.exec_module(module)
29+
return module
30+
31+
32+
@pytest.fixture(scope="module")
33+
def user_manager_module():
34+
if not USER_MANAGER_PATH.exists():
35+
pytest.skip("calendar_env user_manager.py not present in this checkout")
36+
try:
37+
return _load_user_manager_module()
38+
except Exception as exc: # pragma: no cover - import-time failure
39+
pytest.skip(f"calendar_env server deps unavailable: {exc}")
40+
41+
42+
def test_token_fingerprint_is_deterministic_short_sha256(user_manager_module):
43+
"""Fingerprint must be the first 8 chars of sha256(token), no exceptions."""
44+
token = "ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2"
45+
expected = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
46+
47+
fp = user_manager_module._token_fingerprint(token)
48+
49+
assert fp == expected
50+
assert len(fp) == 8
51+
assert token not in fp
52+
53+
54+
def test_token_fingerprint_handles_empty(user_manager_module):
55+
assert user_manager_module._token_fingerprint("") == "<empty>"
56+
assert user_manager_module._token_fingerprint(None) == "<empty>"
57+
58+
59+
def test_get_user_by_access_token_does_not_log_token(
60+
user_manager_module, monkeypatch, caplog
61+
):
62+
"""When the DB session raises, the raw token must not appear in logs."""
63+
UserManager = user_manager_module.UserManager
64+
token = "ya29.A0ARrdaM-SECRET-TOKEN-VALUE-do-not-leak"
65+
66+
failing_session = MagicMock()
67+
failing_session.query.side_effect = RuntimeError("simulated DB failure")
68+
monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session)
69+
monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None)
70+
71+
manager = UserManager.__new__(UserManager)
72+
manager.database_id = "test-db"
73+
74+
with caplog.at_level(logging.ERROR, logger=user_manager_module.__name__):
75+
with pytest.raises(RuntimeError, match="simulated DB failure"):
76+
manager.get_user_by_access_token(token)
77+
78+
assert token not in caplog.text, "raw access token leaked into log output"
79+
expected_fp = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
80+
assert f"fingerprint={expected_fp}" in caplog.text, (
81+
"log line should include a token fingerprint for correlation"
82+
)
83+
failing_session.close.assert_called_once()
84+
85+
86+
def test_get_user_by_access_token_does_not_leak_token_via_exception_str(
87+
user_manager_module, monkeypatch, caplog
88+
):
89+
"""The token must not leak via str(exc) either.
90+
91+
SQLAlchemy StatementError / DBAPIError include the bound parameters dict
92+
in their string form, e.g.:
93+
'... [parameters: {"static_token_1": "ya29..."}] ...'
94+
A naive `logger.error(f"...: {e}")` would emit the raw token even with
95+
the f-string's other slots sanitized. The current implementation logs
96+
only `type(e).__name__`, so this test pins that behavior.
97+
"""
98+
UserManager = user_manager_module.UserManager
99+
token = "ya29.A0ARrdaM-SECRET-INSIDE-EXC-STR"
100+
101+
class FakeStatementError(Exception):
102+
def __str__(self) -> str:
103+
return (
104+
"(sqlite3.OperationalError) no such column "
105+
"[SQL: SELECT users.* FROM users WHERE users.static_token = ?] "
106+
f"[parameters: ({token!r},)]"
107+
)
108+
109+
failing_session = MagicMock()
110+
failing_session.query.side_effect = FakeStatementError()
111+
monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session)
112+
monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None)
113+
114+
manager = UserManager.__new__(UserManager)
115+
manager.database_id = "test-db"
116+
117+
with caplog.at_level(logging.ERROR, logger=user_manager_module.__name__):
118+
with pytest.raises(FakeStatementError):
119+
manager.get_user_by_access_token(token)
120+
121+
assert token not in caplog.text, "raw token leaked via str(exception) in log output"
122+
expected_fp = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
123+
assert f"fingerprint={expected_fp}" in caplog.text
124+
assert "FakeStatementError" in caplog.text, (
125+
"log should still identify the exception type for diagnostics"
126+
)
127+
assert "[parameters:" not in caplog.text, (
128+
"the SQLAlchemy-style parameter dump must not reach the log sink"
129+
)
130+
131+
132+
def test_get_user_by_access_token_propagates_original_exception(
133+
user_manager_module, monkeypatch
134+
):
135+
"""Sanity: redacting the log line must not swallow the underlying error."""
136+
UserManager = user_manager_module.UserManager
137+
138+
failing_session = MagicMock()
139+
failing_session.query.side_effect = ValueError("boom")
140+
monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session)
141+
monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None)
142+
143+
manager = UserManager.__new__(UserManager)
144+
manager.database_id = "test-db"
145+
146+
with pytest.raises(ValueError, match="boom"):
147+
manager.get_user_by_access_token("any-token")

0 commit comments

Comments
 (0)