diff --git a/src/core/zowe/core_for_zowe_sdk/logger.py b/src/core/zowe/core_for_zowe_sdk/logger.py index 7c4a5029..fa63159a 100644 --- a/src/core/zowe/core_for_zowe_sdk/logger.py +++ b/src/core/zowe/core_for_zowe_sdk/logger.py @@ -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") diff --git a/src/core/zowe/core_for_zowe_sdk/request_handler.py b/src/core/zowe/core_for_zowe_sdk/request_handler.py index f3759d75..3b7d308a 100644 --- a/src/core/zowe/core_for_zowe_sdk/request_handler.py +++ b/src/core/zowe/core_for_zowe_sdk/request_handler.py @@ -10,6 +10,7 @@ Copyright Contributors to the Zowe Project. """ +import copy from typing import Union, Any from requests import Response @@ -19,6 +20,30 @@ from .exceptions import InvalidRequestMethod, RequestFailed, UnexpectedStatus from .logger import Log +SENSITIVE_HEADERS = {"authorization", "cookie", "x-csrf-zosmf-header"} +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: """ @@ -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) @@ -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}" diff --git a/tests/unit/core/test_logger.py b/tests/unit/core/test_logger.py index c0c049ac..c704e84f 100644 --- a/tests/unit/core/test_logger.py +++ b/tests/unit/core/test_logger.py @@ -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 @@ -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) diff --git a/tests/unit/core/test_request_handler.py b/tests/unit/core/test_request_handler.py index fe2364b5..f3bbd967 100644 --- a/tests/unit/core/test_request_handler.py +++ b/tests/unit/core/test_request_handler.py @@ -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)