Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 466608f

Browse files
authoredNov 6, 2024
fix(logs): redact sensitive headers (#1850)
1 parent 74522ad commit 466608f

File tree

6 files changed

+223
-3
lines changed

6 files changed

+223
-3
lines changed
 

‎mypy.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
pretty = True
33
show_error_codes = True
44

5-
# Exclude _files.py because mypy isn't smart enough to apply
5+
# Exclude _files.py and _logs.py because mypy isn't smart enough to apply
66
# the correct type narrowing and as this is an internal module
77
# it's fine to just use Pyright.
8-
exclude = ^(src/openai/_files\.py|_dev/.*\.py)$
8+
exclude = ^(src/openai/_files\.py|src/openai/_utils/_logs\.py|_dev/.*\.py)$
99

1010
strict_equality = True
1111
implicit_reexport = True

‎src/openai/_base_client.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
HttpxRequestFiles,
6363
ModelBuilderProtocol,
6464
)
65-
from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
65+
from ._utils import SensitiveHeadersFilter, is_dict, is_list, asyncify, is_given, lru_cache, is_mapping
6666
from ._compat import model_copy, model_dump
6767
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
6868
from ._response import (
@@ -90,6 +90,7 @@
9090
from ._legacy_response import LegacyAPIResponse
9191

9292
log: logging.Logger = logging.getLogger(__name__)
93+
log.addFilter(SensitiveHeadersFilter())
9394

9495
# TODO: make base page type vars covariant
9596
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")

‎src/openai/_utils/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._logs import SensitiveHeadersFilter as SensitiveHeadersFilter
12
from ._sync import asyncify as asyncify
23
from ._proxy import LazyProxy as LazyProxy
34
from ._utils import (

‎src/openai/_utils/_logs.py

+17
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import os
22
import logging
3+
from typing_extensions import override
4+
5+
from ._utils import is_dict
36

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

710

11+
SENSITIVE_HEADERS = {"api-key", "authorization"}
12+
13+
814
def _basic_config() -> None:
915
# 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"
1016
logging.basicConfig(
@@ -23,3 +29,14 @@ def setup_logging() -> None:
2329
_basic_config()
2430
logger.setLevel(logging.INFO)
2531
httpx_logger.setLevel(logging.INFO)
32+
33+
34+
class SensitiveHeadersFilter(logging.Filter):
35+
@override
36+
def filter(self, record: logging.LogRecord) -> bool:
37+
if is_dict(record.args) and "headers" in record.args and is_dict(record.args["headers"]):
38+
headers = record.args["headers"] = {**record.args["headers"]}
39+
for header in headers:
40+
if str(header).lower() in SENSITIVE_HEADERS:
41+
headers[header] = "<redacted>"
42+
return True

‎tests/lib/test_azure.py

+101
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import logging
12
from typing import Union, cast
23
from typing_extensions import Literal, Protocol
34

45
import httpx
56
import pytest
67
from respx import MockRouter
78

9+
from openai._utils import SensitiveHeadersFilter, is_dict
810
from openai._models import FinalRequestOptions
911
from openai.lib.azure import AzureOpenAI, AsyncAzureOpenAI
1012

@@ -148,3 +150,102 @@ def token_provider() -> str:
148150

149151
assert calls[0].request.headers.get("Authorization") == "Bearer first"
150152
assert calls[1].request.headers.get("Authorization") == "Bearer second"
153+
154+
155+
class TestAzureLogging:
156+
157+
@pytest.fixture(autouse=True)
158+
def logger_with_filter(self) -> logging.Logger:
159+
logger = logging.getLogger("openai")
160+
logger.setLevel(logging.DEBUG)
161+
logger.addFilter(SensitiveHeadersFilter())
162+
return logger
163+
164+
@pytest.mark.respx()
165+
def test_azure_api_key_redacted(self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture) -> None:
166+
respx_mock.post(
167+
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
168+
).mock(
169+
return_value=httpx.Response(200, json={"model": "gpt-4"})
170+
)
171+
172+
client = AzureOpenAI(
173+
api_version="2024-06-01",
174+
api_key="example_api_key",
175+
azure_endpoint="https://example-resource.azure.openai.com",
176+
)
177+
178+
with caplog.at_level(logging.DEBUG):
179+
client.chat.completions.create(messages=[], model="gpt-4")
180+
181+
for record in caplog.records:
182+
if is_dict(record.args) and record.args.get("headers") and is_dict(record.args["headers"]):
183+
assert record.args["headers"]["api-key"] == "<redacted>"
184+
185+
186+
@pytest.mark.respx()
187+
def test_azure_bearer_token_redacted(self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture) -> None:
188+
respx_mock.post(
189+
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
190+
).mock(
191+
return_value=httpx.Response(200, json={"model": "gpt-4"})
192+
)
193+
194+
client = AzureOpenAI(
195+
api_version="2024-06-01",
196+
azure_ad_token="example_token",
197+
azure_endpoint="https://example-resource.azure.openai.com",
198+
)
199+
200+
with caplog.at_level(logging.DEBUG):
201+
client.chat.completions.create(messages=[], model="gpt-4")
202+
203+
for record in caplog.records:
204+
if is_dict(record.args) and record.args.get("headers") and is_dict(record.args["headers"]):
205+
assert record.args["headers"]["Authorization"] == "<redacted>"
206+
207+
208+
@pytest.mark.asyncio
209+
@pytest.mark.respx()
210+
async def test_azure_api_key_redacted_async(self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture) -> None:
211+
respx_mock.post(
212+
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
213+
).mock(
214+
return_value=httpx.Response(200, json={"model": "gpt-4"})
215+
)
216+
217+
client = AsyncAzureOpenAI(
218+
api_version="2024-06-01",
219+
api_key="example_api_key",
220+
azure_endpoint="https://example-resource.azure.openai.com",
221+
)
222+
223+
with caplog.at_level(logging.DEBUG):
224+
await client.chat.completions.create(messages=[], model="gpt-4")
225+
226+
for record in caplog.records:
227+
if is_dict(record.args) and record.args.get("headers") and is_dict(record.args["headers"]):
228+
assert record.args["headers"]["api-key"] == "<redacted>"
229+
230+
231+
@pytest.mark.asyncio
232+
@pytest.mark.respx()
233+
async def test_azure_bearer_token_redacted_async(self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture) -> None:
234+
respx_mock.post(
235+
"https://example-resource.azure.openai.com/openai/deployments/gpt-4/chat/completions?api-version=2024-06-01"
236+
).mock(
237+
return_value=httpx.Response(200, json={"model": "gpt-4"})
238+
)
239+
240+
client = AsyncAzureOpenAI(
241+
api_version="2024-06-01",
242+
azure_ad_token="example_token",
243+
azure_endpoint="https://example-resource.azure.openai.com",
244+
)
245+
246+
with caplog.at_level(logging.DEBUG):
247+
await client.chat.completions.create(messages=[], model="gpt-4")
248+
249+
for record in caplog.records:
250+
if is_dict(record.args) and record.args.get("headers") and is_dict(record.args["headers"]):
251+
assert record.args["headers"]["Authorization"] == "<redacted>"

‎tests/test_utils/test_logging.py

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import logging
2+
from typing import Any, Dict, cast
3+
4+
import pytest
5+
6+
from openai._utils import SensitiveHeadersFilter
7+
8+
9+
@pytest.fixture
10+
def logger_with_filter() -> logging.Logger:
11+
logger = logging.getLogger("test_logger")
12+
logger.setLevel(logging.DEBUG)
13+
logger.addFilter(SensitiveHeadersFilter())
14+
return logger
15+
16+
17+
def test_keys_redacted(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
18+
with caplog.at_level(logging.DEBUG):
19+
logger_with_filter.debug(
20+
"Request options: %s",
21+
{
22+
"method": "post",
23+
"url": "chat/completions",
24+
"headers": {"api-key": "12345", "Authorization": "Bearer token"},
25+
},
26+
)
27+
28+
log_record = cast(Dict[str, Any], caplog.records[0].args)
29+
assert log_record["method"] == "post"
30+
assert log_record["url"] == "chat/completions"
31+
assert log_record["headers"]["api-key"] == "<redacted>"
32+
assert log_record["headers"]["Authorization"] == "<redacted>"
33+
assert (
34+
caplog.messages[0]
35+
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'api-key': '<redacted>', 'Authorization': '<redacted>'}}"
36+
)
37+
38+
39+
def test_keys_redacted_case_insensitive(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
40+
with caplog.at_level(logging.DEBUG):
41+
logger_with_filter.debug(
42+
"Request options: %s",
43+
{
44+
"method": "post",
45+
"url": "chat/completions",
46+
"headers": {"Api-key": "12345", "authorization": "Bearer token"},
47+
},
48+
)
49+
50+
log_record = cast(Dict[str, Any], caplog.records[0].args)
51+
assert log_record["method"] == "post"
52+
assert log_record["url"] == "chat/completions"
53+
assert log_record["headers"]["Api-key"] == "<redacted>"
54+
assert log_record["headers"]["authorization"] == "<redacted>"
55+
assert (
56+
caplog.messages[0]
57+
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'Api-key': '<redacted>', 'authorization': '<redacted>'}}"
58+
)
59+
60+
61+
def test_no_headers(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
62+
with caplog.at_level(logging.DEBUG):
63+
logger_with_filter.debug(
64+
"Request options: %s",
65+
{"method": "post", "url": "chat/completions"},
66+
)
67+
68+
log_record = cast(Dict[str, Any], caplog.records[0].args)
69+
assert log_record["method"] == "post"
70+
assert log_record["url"] == "chat/completions"
71+
assert "api-key" not in log_record
72+
assert "Authorization" not in log_record
73+
assert caplog.messages[0] == "Request options: {'method': 'post', 'url': 'chat/completions'}"
74+
75+
76+
def test_headers_without_sensitive_info(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
77+
with caplog.at_level(logging.DEBUG):
78+
logger_with_filter.debug(
79+
"Request options: %s",
80+
{
81+
"method": "post",
82+
"url": "chat/completions",
83+
"headers": {"custom": "value"},
84+
},
85+
)
86+
87+
log_record = cast(Dict[str, Any], caplog.records[0].args)
88+
assert log_record["method"] == "post"
89+
assert log_record["url"] == "chat/completions"
90+
assert log_record["headers"] == {"custom": "value"}
91+
assert (
92+
caplog.messages[0]
93+
== "Request options: {'method': 'post', 'url': 'chat/completions', 'headers': {'custom': 'value'}}"
94+
)
95+
96+
97+
def test_standard_debug_msg(logger_with_filter: logging.Logger, caplog: pytest.LogCaptureFixture) -> None:
98+
with caplog.at_level(logging.DEBUG):
99+
logger_with_filter.debug("Sending HTTP Request: %s %s", "POST", "chat/completions")
100+
assert caplog.messages[0] == "Sending HTTP Request: POST chat/completions"

0 commit comments

Comments
 (0)
Please sign in to comment.