Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(logs): redact sensitive headers #1850

Merged
merged 10 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
3 changes: 2 additions & 1 deletion src/openai/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
HttpxRequestFiles,
ModelBuilderProtocol,
)
from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
from ._utils import SensitiveHeadersFilter, is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
from ._compat import model_copy, model_dump
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
from ._response import (
Expand Down Expand Up @@ -90,6 +90,7 @@
from ._legacy_response import LegacyAPIResponse

log: logging.Logger = logging.getLogger(__name__)
log.addFilter(SensitiveHeadersFilter())

# TODO: make base page type vars covariant
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
Expand Down
1 change: 1 addition & 0 deletions src/openai/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ._logs import SensitiveHeadersFilter as SensitiveHeadersFilter
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
from ._utils import (
Expand Down
17 changes: 17 additions & 0 deletions src/openai/_utils/_logs.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import os
import logging
from typing_extensions import override

from ._utils import is_dict

logger: logging.Logger = logging.getLogger("openai")
httpx_logger: logging.Logger = logging.getLogger("httpx")


SENSITIVE_HEADERS = {"api-key", "authorization"}


def _basic_config() -> None:
# e.g. [2023-10-05 14:12:26 - openai._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK"
logging.basicConfig(
Expand All @@ -23,3 +29,14 @@ def setup_logging() -> None:
_basic_config()
logger.setLevel(logging.INFO)
httpx_logger.setLevel(logging.INFO)


class SensitiveHeadersFilter(logging.Filter):
@override
def filter(self, record: logging.LogRecord) -> bool:
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
headers = record.args["headers"] = {**record.args["headers"]}
for header in headers:
if str(header).lower() in SENSITIVE_HEADERS:
headers[header] = "<redacted>"
return True
100 changes: 100 additions & 0 deletions tests/lib/test_azure.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
from typing import Union, cast
from typing_extensions import Literal, Protocol

import httpx
import pytest
from respx import MockRouter

from openai._utils import SensitiveHeadersFilter, is_dict
from openai._models import FinalRequestOptions
from openai.lib.azure import AzureOpenAI, AsyncAzureOpenAI

Expand All @@ -28,6 +30,14 @@ class MockRequestCall(Protocol):
request: httpx.Request


@pytest.fixture
def _logger_with_filter() -> logging.Logger:
logger = logging.getLogger("openai")
logger.setLevel(logging.DEBUG)
logger.addFilter(SensitiveHeadersFilter())
return logger


@pytest.mark.parametrize("client", [sync_client, async_client])
def test_implicit_deployment_path(client: Client) -> None:
req = client._build_request(
Expand Down Expand Up @@ -148,3 +158,93 @@ def token_provider() -> str:

assert calls[0].request.headers.get("Authorization") == "Bearer first"
assert calls[1].request.headers.get("Authorization") == "Bearer second"


@pytest.mark.respx()
def test_azure_api_key_redacted(respx_mock: MockRouter, _logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
Copy link
Collaborator

@RobertCraigie RobertCraigie Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I think you could've avoided the _logger_with_filter stuff by putting all these tests in a class and then doing

@pytest.fixture(autouse=True)
def _logger_with_filter() -> logging.Logger: ...

but totally not worth blocking on that change

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. Moved the tests in a class, however ruff still complains about the unused logger function, so kept it with underscore prefix.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah sorry tbc I meant that the class would just be for the logging tests as I assumed we wouldn't want the fixture to be setup for the other tests but if that would be fine then you don't need a class at all and it can just be at the module level

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! I completely misunderstood, my bad 😆. Updating...

respx_mock.post(
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
).mock(
return_value=httpx.Response(200, json={"model": "gpt-4"})
)

client = AzureOpenAI(
api_version="2024-06-01",
api_key="example_api_key",
azure_endpoint="https://example-resource.azure.openai.com",
)

with caplog.at_level(logging.DEBUG):
client.chat.completions.create(messages=[], model="gpt-4")

for record in caplog.records:
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
assert record.args["headers"]["api-key"] == "<redacted>"


@pytest.mark.respx()
def test_azure_bearer_token_redacted(respx_mock: MockRouter, _logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
respx_mock.post(
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
).mock(
return_value=httpx.Response(200, json={"model": "gpt-4"})
)

client = AzureOpenAI(
api_version="2024-06-01",
azure_ad_token="example_token",
azure_endpoint="https://example-resource.azure.openai.com",
)

with caplog.at_level(logging.DEBUG):
client.chat.completions.create(messages=[], model="gpt-4")

for record in caplog.records:
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
assert record.args["headers"]["Authorization"] == "<redacted>"


@pytest.mark.asyncio
@pytest.mark.respx()
async def test_azure_api_key_redacted_async(respx_mock: MockRouter, _logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
respx_mock.post(
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
).mock(
return_value=httpx.Response(200, json={"model": "gpt-4"})
)

client = AsyncAzureOpenAI(
api_version="2024-06-01",
api_key="example_api_key",
azure_endpoint="https://example-resource.azure.openai.com",
)

with caplog.at_level(logging.DEBUG):
await client.chat.completions.create(messages=[], model="gpt-4")

for record in caplog.records:
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
assert record.args["headers"]["api-key"] == "<redacted>"


@pytest.mark.asyncio
@pytest.mark.respx()
async def test_azure_bearer_token_redacted_async(respx_mock: MockRouter, _logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
respx_mock.post(
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
).mock(
return_value=httpx.Response(200, json={"model": "gpt-4"})
)

client = AsyncAzureOpenAI(
api_version="2024-06-01",
azure_ad_token="example_token",
azure_endpoint="https://example-resource.azure.openai.com",
)

with caplog.at_level(logging.DEBUG):
await client.chat.completions.create(messages=[], model="gpt-4")

for record in caplog.records:
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
assert record.args["headers"]["Authorization"] == "<redacted>"
100 changes: 100 additions & 0 deletions tests/test_utils/test_logging.py
kristapratico marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import logging
from typing import Any, Dict, cast

import pytest

from openai._utils import SensitiveHeadersFilter


@pytest.fixture
def logger_with_filter() -> logging.Logger:
logger = logging.getLogger("test_logger")
logger.setLevel(logging.DEBUG)
logger.addFilter(SensitiveHeadersFilter())
return logger


def test_keys_redacted(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug(
"Request options: %s",
{
"method": "post",
"url": "chat/completions",
"headers": {"api-key": "12345", "Authorization": "Bearer token"},
},
)

log_record = cast(Dict[str, Any], caplog.records[0].args)
assert log_record["method"] == "post"
assert log_record["url"] == "chat/completions"
assert log_record["headers"]["api-key"] == "<redacted>"
assert log_record["headers"]["Authorization"] == "<redacted>"
kristapratico marked this conversation as resolved.
Show resolved Hide resolved
assert (
caplog.messages[0]
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'api-key': '<redacted>', 'Authorization': '<redacted>'}}"
)


def test_keys_redacted_case_insensitive(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug(
"Request options: %s",
{
"method": "post",
"url": "chat/completions",
"headers": {"Api-key": "12345", "authorization": "Bearer token"},
},
)

log_record = cast(Dict[str, Any], caplog.records[0].args)
assert log_record["method"] == "post"
assert log_record["url"] == "chat/completions"
assert log_record["headers"]["Api-key"] == "<redacted>"
assert log_record["headers"]["authorization"] == "<redacted>"
assert (
caplog.messages[0]
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'Api-key': '<redacted>', 'authorization': '<redacted>'}}"
)


def test_no_headers(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug(
"Request options: %s",
{"method": "post", "url": "chat/completions"},
)

log_record = cast(Dict[str, Any], caplog.records[0].args)
assert log_record["method"] == "post"
assert log_record["url"] == "chat/completions"
assert "api-key" not in log_record
assert "Authorization" not in log_record
assert caplog.messages[0] == "Request options: {'method': 'post', 'url': 'chat/completions'}"


def test_headers_without_sensitive_info(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug(
"Request options: %s",
{
"method": "post",
"url": "chat/completions",
"headers": {"custom": "value"},
},
)

log_record = cast(Dict[str, Any], caplog.records[0].args)
assert log_record["method"] == "post"
assert log_record["url"] == "chat/completions"
assert log_record["headers"] == {"custom": "value"}
assert (
caplog.messages[0]
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'custom': 'value'}}"
)


def test_standard_debug_msg(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.DEBUG):
logger_with_filter.debug("Sending HTTP Request: %s %s", "POST", "chat/completions")
assert caplog.messages[0] == "Sending HTTP Request: POST chat/completions"
Loading