Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
38 changes: 26 additions & 12 deletions client/python/apache_polaris/cli/log_sanitizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,35 @@
OAUTH_TOKEN_BODY_REDACTED = "<redacted sensitive authentication payload>"
SANITIZE_FAILURE_MESSAGE = "<redacted: unable to sanitize payload>"

SENSITIVE_BODY_KEYS = frozenset({"client_secret", "access_token", "refresh_token"})
# The Polaris management API serializes bodies by alias (camelCase — e.g.
# ``clientSecret``, ``bearerToken``), while the OAuth token endpoint uses
# snake_case. Matching on the normalized key covers both.
SENSITIVE_BODY_KEYS = frozenset(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would preferred we be more specific on this as all sensitive keys should be known. Also, this doesn't cover case for s3, gcs, and adls as they can be present in the body during debug mode as well. I would change to following instead:

         "client_secret",
         "clientSecret",
         "access_token",
         "accessToken",
         "refresh_token",
         "refreshToken",
         "bearerToken",
         "token",
         "password",
         "secret",
         "s3.secret-access-key",
         "s3.session-token",
         "gcs.oauth2.token",
         "adls.sas-token",

{
"clientsecret",
"accesstoken",
"refreshtoken",
"bearertoken",
"token",
"password",
"secret",
}
)


def _is_sensitive_key(key: Any) -> bool:
return (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For this, we will need to change to following to captured known keys as well as adls specific where they can put adls.sas-token as prefix (reference: https://github.com/apache/polaris/blob/main/site/content/in-dev/unreleased/configuration/config-sections/storage-azure.md?plain=1):

    return key in SENSITIVE_BODY_KEYS or (
        isinstance(key, str) and key.startswith("adls.sas-token")
     )

isinstance(key, str)
and key.replace("_", "").replace("-", "").lower() in SENSITIVE_BODY_KEYS
)


def sanitize_data(data: Any) -> Any:
if isinstance(data, dict):
sanitized: dict[Any, Any] = {}
for key, value in data.items():
if key in SENSITIVE_BODY_KEYS:
if isinstance(value, (dict, list, tuple)):
sanitized[key] = sanitize_data(value)
else:
sanitized[key] = REDACTED
else:
sanitized[key] = sanitize_data(value)
return sanitized
return {
key: REDACTED if _is_sensitive_key(key) else sanitize_data(value)
for key, value in data.items()
}
if isinstance(data, list):
return [sanitize_data(item) for item in data]
if isinstance(data, tuple):
Expand All @@ -69,7 +83,7 @@ def is_oauth_token_endpoint(url: str) -> bool:

def _sanitize_form_body(body: str) -> str:
sanitized_pairs = [
(key, REDACTED if key in SENSITIVE_BODY_KEYS else value)
(key, REDACTED if _is_sensitive_key(key) else value)
for key, value in parse_qsl(body, keep_blank_values=True)
]
return urlencode(sanitized_pairs, safe="*")
Expand Down
66 changes: 66 additions & 0 deletions client/python/tests/test_log_sanitizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ def test_malformed_json_does_not_raise(self) -> None:
sanitized = sanitize_body(body)
self.assertEqual(sanitized, body)

def test_credential_key_spellings_are_redacted(self) -> None:
for key in (
"clientSecret",
"accessToken",
"refreshToken",
"bearerToken",
"client-secret",
"CLIENT_SECRET",
"Bearer-Token",
):
with self.subTest(key=key):
self.assertEqual(sanitize_data({key: "s"})[key], REDACTED)

def test_sensitive_key_with_structured_value_is_redacted(self) -> None:
# A sensitive key with a dict/list/tuple value must be fully redacted;
# earlier revisions recursed into the value, which left secrets in place.
payload = {
"clientSecret": {"v": "leak"},
"accessToken": ["t1", "t2"],
}
sanitized = sanitize_data(payload)
self.assertEqual(sanitized["clientSecret"], REDACTED)
self.assertEqual(sanitized["accessToken"], REDACTED)

def test_non_sensitive_lookalike_keys_are_preserved(self) -> None:
payload = {
"tokenType": "Bearer",
"expiresIn": 3600,
"clientId": "my-client",
}
sanitized = sanitize_data(payload)
self.assertEqual(sanitized, payload)

def test_sanitize_failures_return_safe_fallback(self) -> None:
stderr = io.StringIO()
with patch("apache_polaris.cli.log_sanitizer.sys.stderr", stderr):
Expand Down Expand Up @@ -238,6 +271,39 @@ def test_debug_logging_redacts_management_request_credentials(self) -> None:
self.assertIn('"name": "sales"', output)
self.assertIn('"client_id": "my-client"', output)

def test_debug_logging_redacts_camelcase_credentials_on_the_wire(self) -> None:
response_body = json.dumps(
{
"principal": {"name": "alice", "clientId": "abc"},
"credentials": {"clientId": "abc", "clientSecret": "hunter2"},
}
).encode()
output = self._capture_debug_output(
url="http://localhost:8181/api/management/v1/principals",
headers={"Authorization": "Bearer admin-token"},
body=json.dumps(
{
"name": "ext",
"connectionConfigInfo": {
"authenticationParameters": {
"clientId": "id",
"clientSecret": "topsecret",
"bearerToken": "btok",
}
},
}
),
response_data=response_body,
)

self.assertNotIn("admin-token", output)
self.assertNotIn("topsecret", output)
self.assertNotIn("btok", output)
self.assertNotIn("hunter2", output)
self.assertIn('"name": "ext"', output)
self.assertIn('"clientId": "id"', output)
self.assertIn('"clientId": "abc"', output)

def test_debug_logging_survives_sanitizer_failures(self) -> None:
stderr = io.StringIO()
pool = urllib3.PoolManager()
Expand Down