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
128 changes: 128 additions & 0 deletions code_tests/unit_tests/test_util/test_custom_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import io
import logging
import sys
from collections.abc import Iterator

import httpx
import pytest

from forecasting_tools.util.custom_logger import HttpApiKeyFilter

FAKE_SERPAPI_KEY = "3f9c2a7be41d8e0f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
FAKE_GOOGLE_KEY = "AIzaSyD-fake0key1for2tests3only4abcdefg"
SERPAPI_URL = (
"https://serpapi.com/search.json?engine=google_trends_trending_now&geo=US"
f"&category_id=3&api_key={FAKE_SERPAPI_KEY}"
)


@pytest.fixture
def captured_log() -> Iterator[tuple[logging.Logger, io.StringIO]]:
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(name)s - %(message)s"))
handler.addFilter(HttpApiKeyFilter())
logger = logging.getLogger("metaculus_ai_sdk.seed.sources.google_trends")
original_level, original_propagate = logger.level, logger.propagate
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.propagate = False
yield logger, stream
logger.removeHandler(handler)
logger.setLevel(original_level)
logger.propagate = original_propagate


def test_key_in_a_non_httpx_warning_is_redacted(
captured_log: tuple[logging.Logger, io.StringIO],
) -> None:
logger, stream = captured_log
logger.warning(
f"Google Trends bucket 'climate' failed: Client error '400 Bad Request' for url '{SERPAPI_URL}'"
)
output = stream.getvalue()
assert FAKE_SERPAPI_KEY not in output
assert "api_key=REDACTED'" in output
assert "engine=google_trends_trending_now&geo=US&category_id=3" in output


def test_key_passed_as_a_format_argument_is_redacted(
captured_log: tuple[logging.Logger, io.StringIO],
) -> None:
logger, stream = captured_log
logger.info(
"HTTP Request: %s %s",
"GET",
f"https://maps.googleapis.com/maps/api/geocode/json?address=Paris&key={FAKE_GOOGLE_KEY}",
)
output = stream.getvalue()
assert FAKE_GOOGLE_KEY not in output
assert "address=Paris&key=REDACTED" in output


def test_key_in_logged_exception_text_is_redacted(
captured_log: tuple[logging.Logger, io.StringIO],
) -> None:
logger, stream = captured_log
request = httpx.Request("GET", SERPAPI_URL)
response = httpx.Response(400, request=request)
try:
response.raise_for_status()
except httpx.HTTPStatusError:
logger.exception("Google Trends request failed")
output = stream.getvalue()
assert "HTTPStatusError" in output
assert FAKE_SERPAPI_KEY not in output
assert "api_key=REDACTED" in output


def test_httpx_request_lines_are_kept_with_the_key_redacted() -> None:
record = logging.LogRecord(
"httpx",
logging.INFO,
__file__,
1,
'HTTP Request: GET %s "HTTP/1.1 200 OK"',
(SERPAPI_URL,),
None,
)
assert HttpApiKeyFilter().filter(record) is True
assert FAKE_SERPAPI_KEY not in record.getMessage()


def test_urls_without_secrets_are_unchanged() -> None:
text = (
"Fetched https://api.example.com/v1/search?keyword=robotaxi&page=2&monkey_count=3 "
"and https://www.metaculus.com/api/posts/?tournaments=fall-futureeval-2026&limit=100"
)
assert HttpApiKeyFilter.redact(text) == text


def test_token_and_secret_parameters_are_redacted() -> None:
text = (
"https://example.com/oauth?client_id=abc&client_secret=s3cr3tValue"
"&access_token=tok_123.456&X-Amz-Signature=deadbeef"
)
redacted = HttpApiKeyFilter.redact(text)
assert "s3cr3tValue" not in redacted
assert "tok_123.456" not in redacted
assert "deadbeef" not in redacted
assert "client_id=abc" in redacted


def test_malformed_format_arguments_do_not_raise_from_the_filter() -> None:
record = logging.LogRecord(
"some.library", logging.INFO, __file__, 1, "value %s %s", ("only-one",), None
)
assert HttpApiKeyFilter().filter(record) is True


def test_traceback_without_secrets_is_left_for_the_handler_to_format() -> None:
try:
raise ValueError("no secrets here")
except ValueError:
record = logging.LogRecord(
"some.library", logging.ERROR, __file__, 1, "failed", None, sys.exc_info()
)
HttpApiKeyFilter().filter(record)
assert record.exc_text is None
49 changes: 33 additions & 16 deletions forecasting_tools/util/custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,43 @@
from forecasting_tools.util import file_manipulation


# Define the filter class
class HttpApiKeyFilter(logging.Filter):
"""A filter to prevent logging API keys in HTTP requests."""

# Compile regex for efficiency - matches http(s) URLs containing 'key='
API_KEY_PATTERN = re.compile(r"https?://.*key=")
"""
Redacts secret query parameters (API keys, tokens, secrets) from URLs in every log
record, including exception text. Any logger can print a request URL (e.g. an HTTP
error message), so this is not limited to httpx.
"""

SECRET_QUERY_PARAMETER_PATTERN = re.compile(
r"([?&](?:[\w-]*(?:key|token|secret|signature)|password)=)[^&\s'\"#]+",
re.IGNORECASE,
)
REDACTED_VALUE = "REDACTED"

def filter(self, record: logging.LogRecord) -> bool:
"""
Filters out log records from httpx containing API keys in URLs.

Returns False if the record should be dropped, True otherwise.
"""
# Check if the log record is from httpx
if record.name == "httpx":
try:
message = record.getMessage()
# Check if the pattern is present in the log message
if self.API_KEY_PATTERN.search(message):
return False # Discard the log record
return True # Keep the log record
except Exception:
# Filters run outside Handler.handleError, so let emit() report the bad format
return True
redacted_message = self.redact(message)
if redacted_message != message:
record.msg = redacted_message
record.args = None
exception_text = record.exc_text
if record.exc_info and not exception_text:
exception_text = logging.Formatter().formatException(record.exc_info)
if exception_text:
redacted_exception_text = self.redact(exception_text)
if redacted_exception_text != exception_text:
record.exc_text = redacted_exception_text
if record.stack_info:
record.stack_info = self.redact(record.stack_info)
return True

@classmethod
def redact(cls, text: str) -> str:
return cls.SECRET_QUERY_PARAMETER_PATTERN.sub(rf"\1{cls.REDACTED_VALUE}", text)


class CustomLogger:
Expand Down
Loading