Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ async def async_response(
previous_response_id: str = None,
log_prefix: str = "response",
):
# extra_headers/request_params carry raw secrets (JWT, provider API keys,
# x-api-key — see langflow_headers.py). Never pass them to a logger call
# (e.g. logger.info(..., extra_headers=extra_headers)) — that logs the
# secrets directly, bypassing the show_locals=False traceback protection
# in utils/logging_config.py.
try:
logger.info("User prompt received", prompt=prompt)

Expand Down
15 changes: 12 additions & 3 deletions src/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ async def langflow_chat(
"LANGFLOW_URL and LANGFLOW_CHAT_FLOW_ID environment variables are required"
)

# Prepare extra headers for JWT authentication and embedding model
# Prepare extra headers for JWT authentication and embedding model.
# NOTE: extra_headers accumulates raw secrets (JWT, provider API keys —
# see add_provider_credentials_to_headers below). Never log this dict or
# pass it to a logger call (e.g. logger.info(..., extra_headers=...)).
extra_headers = {}
if jwt_token:
extra_headers["X-LANGFLOW-GLOBAL-VAR-JWT"] = jwt_token
Expand Down Expand Up @@ -244,7 +247,10 @@ async def langflow_nudges_chat(
if not LANGFLOW_URL or not NUDGES_FLOW_ID:
raise ValueError("LANGFLOW_URL and NUDGES_FLOW_ID environment variables are required")

# Prepare extra headers for JWT authentication and embedding model
# Prepare extra headers for JWT authentication and embedding model.
# NOTE: extra_headers accumulates raw secrets (JWT, provider API keys —
# see add_provider_credentials_to_headers below). Never log this dict or
# pass it to a logger call (e.g. logger.info(..., extra_headers=...)).
extra_headers = {}
if jwt_token:
extra_headers["X-LANGFLOW-GLOBAL-VAR-JWT"] = jwt_token
Expand Down Expand Up @@ -488,7 +494,10 @@ async def upload_context_chat(
conversation_user_id = storage_user_id or user_id

if endpoint == "langflow":
# Prepare extra headers for JWT authentication and embedding model
# Prepare extra headers for JWT authentication and embedding model.
# NOTE: extra_headers accumulates raw secrets (JWT, provider API keys).
# Never log this dict or pass it to a logger call
# (e.g. logger.info(..., extra_headers=...)).
extra_headers = {}
if jwt_token:
extra_headers["X-LANGFLOW-GLOBAL-VAR-JWT"] = jwt_token
Expand Down
4 changes: 4 additions & 0 deletions src/utils/langflow_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ async def add_provider_credentials_to_headers(
jwt_token: Optional credential string (``'Basic <b64>'`` or ``'Bearer <jwt>'``).
When IBM_AUTH_ENABLED, injected as Langflow global variables. Basic
credentials additionally provide OPENSEARCH_USERNAME and OPENSEARCH_PASSWORD.

NOTE: `headers` ends up holding raw API keys/JWTs after this call. Never log
it directly (e.g. logger.info(..., headers=headers) / extra_headers=...) —
use utils.logging_config.sanitize_headers() if a header dict must be logged.
"""
# Add OpenAI credentials
if config.providers.openai.api_key:
Expand Down
10 changes: 8 additions & 2 deletions src/utils/logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,15 @@ def configure_logging(
_shared_processors = list(base_processors)

if use_json:
# Production: structured tracebacks (queryable in ELK/Datadog/Splunk)
# Production: structured tracebacks (queryable in ELK/Datadog/Splunk).
# show_locals=False: local variables routinely hold API keys, JWTs, and
# other request headers (see extra_headers/request_params in agent.py) —
# they must never be serialized into logs. dict_tracebacks defaults to
# show_locals=True, which leaked exactly that into production logs.
render_processors = [
structlog.processors.dict_tracebacks,
structlog.processors.ExceptionRenderer(
structlog.tracebacks.ExceptionDictTransformer(show_locals=False)
),
structlog.processors.JSONRenderer(),
]
else:
Expand Down
59 changes: 59 additions & 0 deletions tests/unit/utils/test_logging_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Unit tests for ``utils.logging_config`` — production JSON logs must never
serialize exception-frame locals, since those routinely hold API keys, JWTs,
and other request headers (see src/agent.py's extra_headers/request_params)."""

import io
import json

import structlog

from utils import logging_config

FAKE_SECRET = "sk-test-should-not-leak-0123456789"


def _raise_with_secret():
api_key = FAKE_SECRET # noqa: F841 - intentionally kept as a local
raise ValueError("boom")


def _capture_json_exception_log() -> dict:
logging_config.configure_logging(json_logs=True, include_timestamps=False)
stream = io.StringIO()
structlog.configure(
processors=structlog.get_config()["processors"],
wrapper_class=structlog.get_config()["wrapper_class"],
context_class=dict,
logger_factory=structlog.WriteLoggerFactory(stream),
cache_logger_on_first_use=True,
)
logger = structlog.get_logger()
try:
_raise_with_secret()
except ValueError:
logger.exception("something failed")
return json.loads(stream.getvalue())


def test_json_exception_logs_do_not_leak_locals():
event = _capture_json_exception_log()

raw = json.dumps(event)
assert FAKE_SECRET not in raw

exception = event["exception"]
assert exception, "expected a rendered exception traceback"
for stack in exception:
for frame in stack.get("frames", []):
assert not frame.get("locals"), (
f"frame {frame.get('name')} unexpectedly serialized locals: {frame.get('locals')}"
)


def test_json_exception_logs_still_include_frame_identity():
event = _capture_json_exception_log()

exception = event["exception"]
frame_names = [f.get("name") for stack in exception for f in stack.get("frames", [])]
assert "_raise_with_secret" in frame_names
assert exception[0]["exc_type"] == "ValueError"
Loading