diff --git a/NEWS b/NEWS index 7e50965f..d84b3883 100644 --- a/NEWS +++ b/NEWS @@ -9,6 +9,7 @@ * #843: Add documentation link to Loki dashboard for borgmatic: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook * #847: Fix "--json" error when Borg includes non-JSON warnings in JSON output. + * #848: SECURITY: Mask the password when logging a MongoDB dump or restore command. * Fix handling of the NO_COLOR environment variable to ignore an empty value. * Add documentation about backing up containerized databases by configuring borgmatic to exec into a container to run a dump command: diff --git a/borgmatic/execute.py b/borgmatic/execute.py index b0712cd5..76442d23 100644 --- a/borgmatic/execute.py +++ b/borgmatic/execute.py @@ -220,6 +220,24 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b } +SECRET_COMMAND_FLAG_NAMES = {'--password'} + + +def mask_command_secrets(full_command): + ''' + Given a command as a sequence, mask secret values for flags like "--password" in preparation for + logging. + ''' + masked_command = [] + previous_piece = None + + for piece in full_command: + masked_command.append('***' if previous_piece in SECRET_COMMAND_FLAG_NAMES else piece) + previous_piece = piece + + return tuple(masked_command) + + MAX_LOGGED_COMMAND_LENGTH = 1000 @@ -231,7 +249,8 @@ def log_command(full_command, input_file=None, output_file=None, environment=Non logger.debug( textwrap.shorten( ' '.join( - tuple(f'{key}=***' for key in (environment or {}).keys()) + tuple(full_command) + tuple(f'{key}=***' for key in (environment or {}).keys()) + + mask_command_secrets(full_command) ), width=MAX_LOGGED_COMMAND_LENGTH, placeholder=' ...', diff --git a/tests/unit/test_execute.py b/tests/unit/test_execute.py index 2d01c80f..82c94206 100644 --- a/tests/unit/test_execute.py +++ b/tests/unit/test_execute.py @@ -117,6 +117,24 @@ def test_append_last_lines_with_output_log_level_none_appends_captured_output(): assert captured_output == ['captured', 'line'] +def test_mask_command_secrets_masks_password_flag_value(): + assert module.mask_command_secrets(('cooldb', '--username', 'bob', '--password', 'pass')) == ( + 'cooldb', + '--username', + 'bob', + '--password', + '***', + ) + + +def test_mask_command_secrets_passes_through_other_commands(): + assert module.mask_command_secrets(('cooldb', '--username', 'bob')) == ( + 'cooldb', + '--username', + 'bob', + ) + + @pytest.mark.parametrize( 'full_command,input_file,output_file,environment,expected_result', ( @@ -149,6 +167,7 @@ def test_append_last_lines_with_output_log_level_none_appends_captured_output(): def test_log_command_logs_command_constructed_from_arguments( full_command, input_file, output_file, environment, expected_result ): + flexmock(module).should_receive('mask_command_secrets').replace_with(lambda command: command) flexmock(module.logger).should_receive('debug').with_args(expected_result).once() module.log_command(full_command, input_file, output_file, environment)