Skip to content
Open
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
10 changes: 8 additions & 2 deletions src/core/zowe/core_for_zowe_sdk/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ class Log:
"""

dirname: str = os.path.join(os.path.expanduser("~"), ".zowe/logs")
os.makedirs(dirname, exist_ok=True)
file_handler: logging.FileHandler = logging.FileHandler(os.path.join(dirname, "python_sdk_logs.log"))
os.makedirs(dirname, mode=0o700, exist_ok=True)
os.chmod(dirname, 0o700)
__log_filename: str = os.path.join(dirname, "python_sdk_logs.log")

__log_fd = os.open(__log_filename, os.O_CREAT | os.O_APPEND, 0o600)
os.close(__log_fd)
os.chmod(__log_filename, 0o600)
file_handler: logging.FileHandler = logging.FileHandler(__log_filename)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(
logging.Formatter("[%(asctime)s] [%(levelname)s] [%(name)s] - %(message)s", "%m/%d/%Y %I:%M:%S %p")
Expand Down
33 changes: 30 additions & 3 deletions src/core/zowe/core_for_zowe_sdk/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Copyright Contributors to the Zowe Project.
"""

import copy
from typing import Union, Any
from requests import Response

Expand All @@ -19,6 +20,30 @@
from .exceptions import InvalidRequestMethod, RequestFailed, UnexpectedStatus
from .logger import Log

SENSITIVE_HEADERS = {"authorization", "cookie", "x-csrf-zosmf-header"}
REDACTED = "<redacted>"


def _redact_headers(headers: Any) -> dict[str, Any]:
"""Return a copy of the given headers with sensitive values redacted."""
if not headers:
return dict(headers) if headers else {}
return {key: (REDACTED if key.lower() in SENSITIVE_HEADERS else value) for key, value in headers.items()}


def _redact_request_arguments(request_arguments: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of request_arguments safe to log, with credentials and headers redacted."""
safe_arguments = copy.copy(request_arguments)
if "auth" in safe_arguments:
safe_arguments["auth"] = REDACTED
if "headers" in safe_arguments:
safe_arguments["headers"] = _redact_headers(safe_arguments["headers"])
if "json" in safe_arguments:
safe_arguments["json"] = REDACTED
if "data" in safe_arguments:
safe_arguments["data"] = REDACTED
return safe_arguments


class RequestHandler:
"""
Expand Down Expand Up @@ -69,7 +94,9 @@ def perform_request(
self.__request_arguments = request_arguments
self.__expected_code = expected_code
self.__logger.debug(
f"Request method: {self.__method}, Request arguments: {self.__request_arguments}, Expected code: {expected_code}"
f"Request method: {self.__method}, "
f"Request arguments: {_redact_request_arguments(self.__request_arguments)}, "
f"Expected code: {expected_code}"
)
self.__validate_method()
self.__send_request(stream=stream)
Expand Down Expand Up @@ -129,8 +156,8 @@ def __validate_response(self) -> None:
raise UnexpectedStatus(self.__expected_code, self.__response.status_code, self.__response.text)
else:
output_str = str(self.__response.request.url)
output_str += "\n" + str(self.__response.request.headers)
output_str += "\n" + str(self.__response.request.body)
output_str += "\n" + str(_redact_headers(self.__response.request.headers))
output_str += "\n" + (REDACTED if self.__response.request.body else "")
output_str += "\n" + str(self.__response.text)
self.__logger.error(
f"HTTP Request has failed with status code {self.__response.status_code}. \n {output_str}"
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/core/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# Including necessary paths
import logging
import os
import stat

from pyfakefs.fake_filesystem_unittest import TestCase
from zowe.core_for_zowe_sdk.logger import Log
Expand Down Expand Up @@ -76,3 +78,13 @@ def test_file_handler(self):

Log.set_file_output_level(logging.ERROR)
self.assertEqual(logging.ERROR, Log.file_handler.level)

def test_log_directory_and_file_are_owner_only(self):
"""The log directory and file should not be readable/writable by group or others, since log
content may include request/response details."""
dir_mode = stat.S_IMODE(os.stat(Log.dirname).st_mode)
self.assertEqual(dir_mode, 0o700)

log_file = os.path.join(Log.dirname, "python_sdk_logs.log")
file_mode = stat.S_IMODE(os.stat(log_file).st_mode)
self.assertEqual(file_mode, 0o600)
41 changes: 41 additions & 0 deletions tests/unit/core/test_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,44 @@ def test_empty_text(self, mock_send_request):
request_handler = RequestHandler(self.session_arguments)
response = request_handler.perform_request("GET", {"url": "https://www.zowe.org"})
self.assertTrue(response == None)

@mock.patch("logging.Logger.debug")
@mock.patch("requests.Session.send")
def test_debug_log_redacts_credentials(self, mock_send_request, mock_logger_debug: mock.MagicMock):
"""The DEBUG request-arguments dump should never contain raw auth or header secrets."""
mock_send_request.return_value = mock.Mock(status_code=200)
request_handler = RequestHandler(self.session_arguments)
request_handler.perform_request(
"GET",
{
"url": "https://www.zowe.org",
"auth": ("user", "super-secret-password"),
"headers": {"Authorization": "Bearer super-secret-token", "Cookie": "LtpaToken2=super-secret-cookie"},
"json": {"password": "super-secret-password"},
},
stream=True,
)
debug_message = mock_logger_debug.call_args[0][0]
self.assertNotIn("super-secret-password", debug_message)
self.assertNotIn("super-secret-token", debug_message)
self.assertNotIn("super-secret-cookie", debug_message)

@mock.patch("logging.Logger.error")
@mock.patch("requests.Session.send")
def test_error_log_and_exception_redact_credentials(self, mock_send_request, mock_logger_error: mock.MagicMock):
"""A failed request should not leak the Authorization/Cookie headers or body into logs or the raised exception."""
mock_request = mock.Mock(
url="https://www.zowe.org",
headers={"Authorization": "Basic super-secret-basic-auth", "Cookie": "LtpaToken2=super-secret-cookie"},
body="super-secret-body-content",
)
mock_send_request.return_value = mock.Mock(ok=False, status_code=500, text="failure", request=mock_request)
request_handler = RequestHandler(self.session_arguments)
with self.assertRaises(exceptions.RequestFailed) as ctx:
request_handler.perform_request("GET", {"url": "https://www.zowe.org"}, stream=True)

error_message = mock_logger_error.call_args[0][0]
exception_message = str(ctx.exception)
for secret in ("super-secret-basic-auth", "super-secret-cookie", "super-secret-body-content"):
self.assertNotIn(secret, error_message)
self.assertNotIn(secret, exception_message)
Loading