blob: 5edc340638898723f30590a4ea510c15eed4105b [file]
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
import io
import json
import unittest
from unittest.mock import MagicMock, patch
import urllib3
from apache_polaris.cli.log_sanitizer import (
OAUTH_TOKEN_BODY_REDACTED,
REDACTED,
SANITIZE_FAILURE_MESSAGE,
is_oauth_token_endpoint,
sanitize_body,
sanitize_body_for_log,
sanitize_data,
sanitize_headers,
safe_sanitize_body_for_log,
safe_sanitize_headers,
)
from apache_polaris.cli.polaris_cli import PolarisCli
class TestLogSanitizer(unittest.TestCase):
def test_authorization_header_redaction(self) -> None:
headers = {
"Authorization": "Bearer secret-token",
"Content-Type": "application/json",
}
sanitized = sanitize_headers(headers)
self.assertEqual(sanitized["Authorization"], REDACTED)
self.assertEqual(sanitized["Content-Type"], "application/json")
def test_oauth_token_request_payload_redaction(self) -> None:
body = (
"grant_type=client_credentials&client_id=my-client&"
"client_secret=super-secret&scope=PRINCIPAL_ROLE:ALL"
)
sanitized = sanitize_body(body)
self.assertIn("client_id=my-client", sanitized)
self.assertIn(f"client_secret={REDACTED}", sanitized)
self.assertNotIn("super-secret", sanitized)
self.assertNotIn("%2A%2A%2A", sanitized)
def test_oauth_token_response_redaction(self) -> None:
body = json.dumps(
{
"access_token": "oauth-access-token",
"refresh_token": "oauth-refresh-token",
"token_type": "Bearer",
"expires_in": 3600,
}
)
sanitized = sanitize_body(body)
parsed = json.loads(sanitized)
self.assertEqual(parsed["access_token"], REDACTED)
self.assertEqual(parsed["refresh_token"], REDACTED)
self.assertEqual(parsed["token_type"], "Bearer")
self.assertEqual(parsed["expires_in"], 3600)
def test_oauth_token_endpoint_matching(self) -> None:
self.assertTrue(
is_oauth_token_endpoint("http://localhost:8080/api/catalog/v1/oauth/tokens")
)
self.assertFalse(
is_oauth_token_endpoint(
"http://localhost:8080/api/catalog/v1/oauth/tokens/extra"
)
)
def test_oauth_token_endpoint_body_fully_redacted(self) -> None:
body = (
"grant_type=client_credentials&client_id=my-client&"
"client_secret=super-secret"
)
url = "http://localhost:8080/api/catalog/v1/oauth/tokens"
self.assertEqual(sanitize_body_for_log(body, url), OAUTH_TOKEN_BODY_REDACTED)
response_body = json.dumps({"access_token": "oauth-access-token"})
self.assertEqual(
sanitize_body_for_log(response_body, url), OAUTH_TOKEN_BODY_REDACTED
)
def test_nested_json_structures_redact_secrets(self) -> None:
payload = {
"principal": {"name": "alice"},
"credentials": {
"client_secret": "nested-secret",
"tokens": [{"access_token": "token-1", "token_type": "Bearer"}],
},
}
sanitized = sanitize_data(payload)
self.assertEqual(sanitized["principal"]["name"], "alice")
self.assertEqual(sanitized["credentials"]["client_secret"], REDACTED)
self.assertEqual(sanitized["credentials"]["tokens"][0]["access_token"], REDACTED)
self.assertEqual(
sanitized["credentials"]["tokens"][0]["token_type"], "Bearer"
)
def test_non_sensitive_fields_remain_visible(self) -> None:
payload = {
"client_id": "my-client",
"warehouse": "dev",
"client_secret": "secret",
}
sanitized = sanitize_data(payload)
self.assertEqual(sanitized["client_id"], "my-client")
self.assertEqual(sanitized["warehouse"], "dev")
self.assertEqual(sanitized["client_secret"], REDACTED)
headers = {"Accept": "application/json", "User-Agent": "polaris-cli"}
self.assertEqual(sanitize_headers(headers), headers)
def test_malformed_json_does_not_raise(self) -> None:
body = "{not-valid-json"
sanitized = sanitize_body(body)
self.assertEqual(sanitized, body)
def test_credential_key_spellings_are_redacted(self) -> None:
for key in (
# OAuth on the management-API camelCase wire
"clientSecret",
"accessToken",
"refreshToken",
"bearerToken",
# OAuth on the snake_case wire (token endpoint / Iceberg REST config)
"client_secret",
"access_token",
"refresh_token",
# Bearer token key in Iceberg REST config
"token",
# Generic defense in depth
"password",
"secret",
# Iceberg vended-credential storage keys (bare forms)
"s3.secret-access-key",
"s3.session-token",
"gcs.oauth2.token",
"adls.sas-token",
):
with self.subTest(key=key):
self.assertEqual(sanitize_data({key: "s"})[key], REDACTED)
def test_adls_sas_token_with_suffix_is_redacted(self) -> None:
# ``adls.sas-token`` is emitted with a hostname or account suffix.
for key in (
"adls.sas-token.myaccount.dfs.core.windows.net",
"adls.sas-token.myaccount",
):
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):
with patch(
"apache_polaris.cli.log_sanitizer.sanitize_headers",
side_effect=RuntimeError("boom"),
):
self.assertEqual(
safe_sanitize_headers({"Authorization": "secret"}),
SANITIZE_FAILURE_MESSAGE,
)
with patch(
"apache_polaris.cli.log_sanitizer.sanitize_body_for_log",
side_effect=RuntimeError("boom"),
):
self.assertEqual(
safe_sanitize_body_for_log('{"token":"secret"}', "http://example"),
SANITIZE_FAILURE_MESSAGE,
)
output = stderr.getvalue()
self.assertIn("Failed to sanitize debug log headers: boom", output)
self.assertIn("Failed to sanitize debug log body: boom", output)
class TestApiRequestLogging(unittest.TestCase):
def tearDown(self) -> None:
if hasattr(urllib3.PoolManager, "original_urlopen"):
urllib3.PoolManager.urlopen = urllib3.PoolManager.original_urlopen
delattr(urllib3.PoolManager, "original_urlopen")
def _capture_debug_output(self, **urlopen_kwargs: object) -> str:
stderr = io.StringIO()
pool = urllib3.PoolManager()
response = MagicMock()
response.status = 200
response.headers = urlopen_kwargs.pop(
"response_headers",
{"Content-Type": "application/json"},
)
response.data = urlopen_kwargs.pop(
"response_data",
json.dumps(
{
"access_token": "oauth-access-token",
"token_type": "Bearer",
"expires_in": 3600,
}
).encode(),
)
with patch("apache_polaris.cli.polaris_cli.sys.stderr", stderr):
PolarisCli._enable_api_request_logging()
with patch.object(
urllib3.PoolManager,
"original_urlopen",
return_value=response,
) as mock_urlopen:
pool.urlopen("POST", **urlopen_kwargs)
mock_urlopen.assert_called_once()
return stderr.getvalue()
def test_debug_logging_redacts_oauth_request_and_response(self) -> None:
output = self._capture_debug_output(
url="http://localhost:8080/api/catalog/v1/oauth/tokens",
headers={
"Authorization": "Bearer secret-token",
"Content-Type": "application/x-www-form-urlencoded",
},
body=(
"grant_type=client_credentials&client_id=my-client&"
"client_secret=super-secret"
),
)
self.assertIn("Authorization", output)
self.assertNotIn("secret-token", output)
self.assertNotIn("super-secret", output)
self.assertNotIn("oauth-access-token", output)
self.assertIn(OAUTH_TOKEN_BODY_REDACTED, output)
self.assertIn("Response Body:", output)
def test_debug_logging_redacts_management_request_credentials(self) -> None:
output = self._capture_debug_output(
url="http://localhost:8181/api/management/v1/catalogs",
headers={
"Authorization": "Bearer secret-token",
"Accept": "application/json",
},
body=json.dumps(
{
"name": "sales",
"client_id": "my-client",
"client_secret": "super-secret",
}
),
response_data=json.dumps({"catalogs": [{"name": "sales"}]}).encode(),
)
self.assertNotIn("secret-token", output)
self.assertNotIn("super-secret", output)
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()
response = MagicMock(status=200, headers={}, data=b"ok")
with (
patch("apache_polaris.cli.polaris_cli.sys.stderr", stderr),
patch(
"apache_polaris.cli.polaris_cli.safe_sanitize_headers",
side_effect=[SANITIZE_FAILURE_MESSAGE, SANITIZE_FAILURE_MESSAGE],
),
patch(
"apache_polaris.cli.polaris_cli.safe_sanitize_body_for_log",
return_value=SANITIZE_FAILURE_MESSAGE,
),
):
PolarisCli._enable_api_request_logging()
with patch.object(
urllib3.PoolManager,
"original_urlopen",
return_value=response,
):
pool.urlopen(
"GET",
"http://localhost:8181/api/management/v1/catalogs",
headers={"Authorization": "secret"},
body='{"token":"secret"}',
)
output = stderr.getvalue()
self.assertIn("Request: GET", output)
self.assertIn("Response: 200", output)
self.assertNotIn("secret", output)
if __name__ == "__main__":
unittest.main()