Browse Source

Use the Bandit security analysis tool when running tests.
SECURITY: Add timeouts to all monitoring hooks to prevent hangs on network requests.
SECURITY: For the "spot" check, use a more secure source of randomness when selecting paths to check.

Dan Helfman 4 days ago
parent
commit
0d54fda27a

+ 4 - 0
NEWS

@@ -8,6 +8,10 @@
  * #1099: Clarify documentation on command hooks order of execution.
  * #1100: Fix a bug in which "borg --version" failing isn't considered a "fail" state in a command
    hook.
+ * Use the Bandit security analysis tool when running tests.
+ * SECURITY: Add timeouts to all monitoring hooks to prevent hangs on network requests.
+ * SECURITY: For the "spot" check, use a more secure source of randomness when selecting paths to
+   check.
 
 2.0.6
  * #1068: Fix a warning from LVM about leaked file descriptors.

+ 1 - 1
borgmatic/actions/check.py

@@ -486,7 +486,7 @@ def compare_spot_check_hashes(
     sample_count = max(
         int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)), 1
     )
-    source_sample_paths = tuple(random.sample(source_paths, sample_count))
+    source_sample_paths = tuple(random.SystemRandom().sample(source_paths, sample_count))
     working_directory = borgmatic.config.paths.get_working_directory(config)
     hashable_source_sample_path = {
         source_path

+ 1 - 1
borgmatic/borg/borg.py

@@ -60,7 +60,7 @@ def run_arbitrary_borg(
     return execute_command(
         tuple(shlex.quote(part) for part in full_command),
         output_file=DO_NOT_CAPTURE,
-        shell=True,
+        shell=True,  # noqa: S604
         environment=dict(
             (environment.make_environment(config) or {}),
             **{

+ 1 - 1
borgmatic/commands/borgmatic.py

@@ -750,7 +750,7 @@ def log_error_records(
     except (ValueError, OSError) as error:
         yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
         yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
-    except:  # noqa: E722
+    except:  # noqa: E722, S110
         # Raising above only as a means of determining the error type. Swallow the exception here
         # because we don't want the exception to propagate out of this function.
         pass

+ 3 - 1
borgmatic/config/paths.py

@@ -103,7 +103,9 @@ class Runtime_directory:
 
             self.temporary_directory = None
         else:
-            base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
+            base_directory = (
+                os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'  # noqa: S108
+            )
 
             if not base_directory.startswith(os.path.sep):
                 raise ValueError('The temporary directory must be an absolute path')

+ 3 - 3
borgmatic/execute.py

@@ -313,7 +313,7 @@ def execute_command(
         stdin=input_file,
         stdout=None if do_not_capture else (output_file or subprocess.PIPE),
         stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT),
-        shell=shell,
+        shell=shell,  # noqa: S602
         env=environment,
         cwd=working_directory,
         close_fds=close_fds,
@@ -363,7 +363,7 @@ def execute_command_and_capture_output(
             command,
             stdin=input_file,
             stderr=subprocess.STDOUT if capture_stderr else None,
-            shell=shell,
+            shell=shell,  # noqa: S602
             env=environment,
             cwd=working_directory,
             close_fds=close_fds,
@@ -423,7 +423,7 @@ def execute_command_with_processes(
             stderr=(
                 None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT)
             ),
-            shell=shell,
+            shell=shell,  # noqa: S602
             env=environment,
             cwd=working_directory,
             close_fds=close_fds,

+ 1 - 1
borgmatic/hooks/command.py

@@ -144,7 +144,7 @@ def execute_hooks(command_hooks, umask, working_directory, dry_run, **context):
                     output_log_level=(
                         logging.ERROR if hook_config.get('after') == 'error' else logging.ANSWER
                     ),
-                    shell=True,
+                    shell=True,  # noqa: S604
                     environment=make_environment(os.environ),
                     working_directory=working_directory,
                 )

+ 4 - 3
borgmatic/hooks/data_source/mongodb.py

@@ -74,11 +74,12 @@ def dump_data_sources(
 
         if dump_format == 'directory':
             dump.create_parent_directory_for_dump(dump_filename)
-            execute_command(command, shell=True)
+            execute_command(command, shell=True)  # noqa: S604
         else:
             dump.create_named_pipe_for_dump(dump_filename)
-            processes.append(execute_command(command, shell=True, run_to_completion=False))
-
+            processes.append(
+                execute_command(command, shell=True, run_to_completion=False)  # noqa: S604
+            )
     if not dry_run:
         patterns.append(
             borgmatic.borg.pattern.Pattern(

+ 2 - 2
borgmatic/hooks/data_source/postgresql.py

@@ -224,7 +224,7 @@ def dump_data_sources(
                 dump.create_parent_directory_for_dump(dump_filename)
                 execute_command(
                     command,
-                    shell=True,
+                    shell=True,  # noqa: S604
                     environment=environment,
                 )
             else:
@@ -232,7 +232,7 @@ def dump_data_sources(
                 processes.append(
                     execute_command(
                         command,
-                        shell=True,
+                        shell=True,  # noqa: S604
                         environment=environment,
                         run_to_completion=False,
                     )

+ 3 - 1
borgmatic/hooks/data_source/sqlite.py

@@ -88,7 +88,9 @@ def dump_data_sources(
             continue
 
         dump.create_named_pipe_for_dump(dump_filename)
-        processes.append(execute_command(command, shell=True, run_to_completion=False))
+        processes.append(
+            execute_command(command, shell=True, run_to_completion=False)  # noqa: S604
+        )
 
     if not dry_run:
         patterns.append(

+ 2 - 1
borgmatic/hooks/monitoring/cronhub.py

@@ -11,6 +11,7 @@ MONITOR_STATE_TO_CRONHUB = {
     monitor.State.FINISH: 'finish',
     monitor.State.FAIL: 'fail',
 }
+TIMEOUT_SECONDS = 10
 
 
 def initialize_monitor(
@@ -45,7 +46,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
     if not dry_run:
         logging.getLogger('urllib3').setLevel(logging.ERROR)
         try:
-            response = requests.get(ping_url)
+            response = requests.get(ping_url, timeout=TIMEOUT_SECONDS)
             if not response.ok:
                 response.raise_for_status()
         except requests.exceptions.RequestException as error:

+ 2 - 1
borgmatic/hooks/monitoring/cronitor.py

@@ -11,6 +11,7 @@ MONITOR_STATE_TO_CRONITOR = {
     monitor.State.FINISH: 'complete',
     monitor.State.FAIL: 'fail',
 }
+TIMEOUT_SECONDS = 10
 
 
 def initialize_monitor(
@@ -40,7 +41,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
     if not dry_run:
         logging.getLogger('urllib3').setLevel(logging.ERROR)
         try:
-            response = requests.get(ping_url)
+            response = requests.get(ping_url, timeout=TIMEOUT_SECONDS)
             if not response.ok:
                 response.raise_for_status()
         except requests.exceptions.RequestException as error:

+ 5 - 1
borgmatic/hooks/monitoring/healthchecks.py

@@ -17,6 +17,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
 
 DEFAULT_PING_BODY_LIMIT_BYTES = 100000
 HANDLER_IDENTIFIER = 'healthchecks'
+TIMEOUT_SECONDS = 10
 
 
 def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
@@ -84,7 +85,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
         logging.getLogger('urllib3').setLevel(logging.ERROR)
         try:
             response = requests.post(
-                ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True)
+                ping_url,
+                data=payload.encode('utf-8'),
+                verify=hook_config.get('verify_tls', True),
+                timeout=TIMEOUT_SECONDS,
             )
             if not response.ok:
                 response.raise_for_status()

+ 4 - 1
borgmatic/hooks/monitoring/loki.py

@@ -15,6 +15,7 @@ MONITOR_STATE_TO_LOKI = {
     monitor.State.FINISH: 'Finished',
     monitor.State.FAIL: 'Failed',
 }
+TIMEOUT_SECONDS = 10
 
 # Threshold at which logs get flushed to loki
 MAX_BUFFER_LINES = 100
@@ -69,7 +70,9 @@ class Loki_log_buffer:
         request_header = {'Content-Type': 'application/json'}
 
         try:
-            result = requests.post(self.url, headers=request_header, data=request_body, timeout=5)
+            result = requests.post(
+                self.url, headers=request_header, data=request_body, timeout=TIMEOUT_SECONDS
+            )
             result.raise_for_status()
         except requests.RequestException:
             logger.warning('Failed to upload logs to loki')

+ 6 - 1
borgmatic/hooks/monitoring/ntfy.py

@@ -7,6 +7,9 @@ import borgmatic.hooks.credential.parse
 logger = logging.getLogger(__name__)
 
 
+TIMEOUT_SECONDS = 10
+
+
 def initialize_monitor(
     ping_url, config, config_filename, monitoring_log_level, dry_run
 ):  # pragma: no cover
@@ -82,7 +85,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
         if not dry_run:
             logging.getLogger('urllib3').setLevel(logging.ERROR)
             try:
-                response = requests.post(f'{base_url}/{topic}', headers=headers, auth=auth)
+                response = requests.post(
+                    f'{base_url}/{topic}', headers=headers, auth=auth, timeout=TIMEOUT_SECONDS
+                )
                 if not response.ok:
                     response.raise_for_status()
             except requests.exceptions.RequestException as error:

+ 4 - 1
borgmatic/hooks/monitoring/pagerduty.py

@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
 EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue'
 DEFAULT_LOGS_PAYLOAD_LIMIT_BYTES = 10000
 HANDLER_IDENTIFIER = 'pagerduty'
+TIMEOUT_SECONDS = 10
 
 
 def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
@@ -94,7 +95,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
 
     logging.getLogger('urllib3').setLevel(logging.ERROR)
     try:
-        response = requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
+        response = requests.post(
+            EVENTS_API_URL, data=payload.encode('utf-8'), timeout=TIMEOUT_SECONDS
+        )
         if not response.ok:
             response.raise_for_status()
     except requests.exceptions.RequestException as error:

+ 2 - 0
borgmatic/hooks/monitoring/pushover.py

@@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
 
 
 EMERGENCY_PRIORITY = 2
+TIMEOUT_SECONDS = 10
 
 
 def initialize_monitor(
@@ -79,6 +80,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
                 'https://api.pushover.net/1/messages.json',
                 headers={'Content-type': 'application/x-www-form-urlencoded'},
                 data=data,
+                timeout=TIMEOUT_SECONDS,
             )
             if not response.ok:
                 response.raise_for_status()

+ 4 - 1
borgmatic/hooks/monitoring/sentry.py

@@ -6,6 +6,9 @@ import requests
 logger = logging.getLogger(__name__)
 
 
+TIMEOUT_SECONDS = 10
+
+
 def initialize_monitor(
     ping_url, config, config_filename, monitoring_log_level, dry_run
 ):  # pragma: no cover
@@ -61,7 +64,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
 
     logging.getLogger('urllib3').setLevel(logging.ERROR)
     try:
-        response = requests.post(f'{cron_url}?status={status}')
+        response = requests.post(f'{cron_url}?status={status}', timeout=TIMEOUT_SECONDS)
         if not response.ok:
             response.raise_for_status()
     except requests.exceptions.RequestException as error:

+ 8 - 1
borgmatic/hooks/monitoring/uptime_kuma.py

@@ -5,6 +5,9 @@ import requests
 logger = logging.getLogger(__name__)
 
 
+TIMEOUT_SECONDS = 10
+
+
 def initialize_monitor(
     push_url, config, config_filename, monitoring_log_level, dry_run
 ):  # pragma: no cover
@@ -37,7 +40,11 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
     logging.getLogger('urllib3').setLevel(logging.ERROR)
 
     try:
-        response = requests.get(f'{push_url}?{query}', verify=hook_config.get('verify_tls', True))
+        response = requests.get(
+            f'{push_url}?{query}',
+            verify=hook_config.get('verify_tls', True),
+            timeout=TIMEOUT_SECONDS,
+        )
         if not response.ok:
             response.raise_for_status()
     except requests.exceptions.RequestException as error:

+ 4 - 1
borgmatic/hooks/monitoring/zabbix.py

@@ -7,6 +7,9 @@ import borgmatic.hooks.credential.parse
 logger = logging.getLogger(__name__)
 
 
+TIMEOUT_SECONDS = 10
+
+
 def initialize_monitor(
     ping_url, config, config_filename, monitoring_log_level, dry_run
 ):  # pragma: no cover
@@ -28,7 +31,7 @@ def send_zabbix_request(server, headers, data):
     logger.debug(f'Sending a "{data["method"]}" request to the Zabbix server')
 
     try:
-        response = requests.post(server, headers=headers, json=data)
+        response = requests.post(server, headers=headers, json=data, timeout=TIMEOUT_SECONDS)
 
         if not response.ok:
             response.raise_for_status()

+ 3 - 0
test_requirements.txt

@@ -1,6 +1,7 @@
 appdirs==1.4.4
 apprise==1.9.3
 attrs==25.3.0
+bandit==1.8.5
 black==25.1.0
 certifi==2025.6.15
 chardet==5.2.0
@@ -8,6 +9,7 @@ click==8.2.1
 codespell==2.4.1
 coverage==7.9.1
 flake8==7.3.0
+flake8-bandit==4.1.1
 flake8-quotes==3.4.0
 flake8-use-fstring==1.4
 flake8-variables-names==0.0.6
@@ -19,6 +21,7 @@ Markdown==3.8.2
 mccabe==0.7.0
 packaging==25.0
 pathspec==0.12.1
+pbr==6.1.1
 pluggy==1.6.0
 py==1.11.0
 pycodestyle==2.14.0

+ 18 - 18
tests/unit/actions/test_check.py

@@ -938,8 +938,8 @@ def test_collect_spot_check_source_paths_uses_working_directory():
 
 
 def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -979,8 +979,8 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
 
 
 def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1020,8 +1020,8 @@ def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes(
 
 
 def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1061,8 +1061,8 @@ def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
 
 
 def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1101,8 +1101,8 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
 
 
 def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_matching():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1138,8 +1138,8 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
 
 
 def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1174,8 +1174,8 @@ def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
 
 
 def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1211,8 +1211,8 @@ def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching()
 
 def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in_chunks():
     flexmock(module).SAMPLE_PATHS_SUBSET_COUNT = 2
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         None,
@@ -1257,8 +1257,8 @@ def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in
 
 
 def test_compare_spot_check_hashes_uses_working_directory_to_access_source_paths():
-    flexmock(module.random).should_receive('sample').replace_with(
-        lambda population, count: population[:count]
+    flexmock(module.random).should_receive('SystemRandom').and_return(
+        flexmock(sample=lambda population, count: population[:count])
     )
     flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
         '/working/dir',

+ 5 - 5
tests/unit/hooks/monitoring/test_cronhub.py

@@ -6,7 +6,7 @@ from borgmatic.hooks.monitoring import cronhub as module
 def test_ping_monitor_rewrites_ping_url_for_start_state():
     hook_config = {'ping_url': 'https://example.com/start/abcdef'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/start/abcdef'
+        'https://example.com/start/abcdef', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -22,7 +22,7 @@ def test_ping_monitor_rewrites_ping_url_for_start_state():
 def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
     hook_config = {'ping_url': 'https://example.com/ping/abcdef'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/start/abcdef'
+        'https://example.com/start/abcdef', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -38,7 +38,7 @@ def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
 def test_ping_monitor_rewrites_ping_url_for_finish_state():
     hook_config = {'ping_url': 'https://example.com/start/abcdef'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/finish/abcdef'
+        'https://example.com/finish/abcdef', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -54,7 +54,7 @@ def test_ping_monitor_rewrites_ping_url_for_finish_state():
 def test_ping_monitor_rewrites_ping_url_for_fail_state():
     hook_config = {'ping_url': 'https://example.com/start/abcdef'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/fail/abcdef'
+        'https://example.com/fail/abcdef', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -105,7 +105,7 @@ def test_ping_monitor_with_other_error_logs_warning():
         module.requests.exceptions.RequestException
     )
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/start/abcdef'
+        'https://example.com/start/abcdef', timeout=int
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
 

+ 8 - 8
tests/unit/hooks/monitoring/test_cronitor.py

@@ -5,9 +5,9 @@ from borgmatic.hooks.monitoring import cronitor as module
 
 def test_ping_monitor_hits_ping_url_for_start_state():
     hook_config = {'ping_url': 'https://example.com'}
-    flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return(
-        flexmock(ok=True)
-    )
+    flexmock(module.requests).should_receive('get').with_args(
+        'https://example.com/run', timeout=int
+    ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
         hook_config,
@@ -22,7 +22,7 @@ def test_ping_monitor_hits_ping_url_for_start_state():
 def test_ping_monitor_hits_ping_url_for_finish_state():
     hook_config = {'ping_url': 'https://example.com'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/complete'
+        'https://example.com/complete', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -38,7 +38,7 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
 def test_ping_monitor_hits_ping_url_for_fail_state():
     hook_config = {'ping_url': 'https://example.com'}
     flexmock(module.requests).should_receive('get').with_args(
-        'https://example.com/fail'
+        'https://example.com/fail', timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -88,9 +88,9 @@ def test_ping_monitor_with_other_error_logs_warning():
     response.should_receive('raise_for_status').and_raise(
         module.requests.exceptions.RequestException
     )
-    flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return(
-        response
-    )
+    flexmock(module.requests).should_receive('get').with_args(
+        'https://example.com/run', timeout=int
+    ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
 
     module.ping_monitor(

+ 13 - 11
tests/unit/hooks/monitoring/test_healthchecks.py

@@ -79,7 +79,7 @@ def test_ping_monitor_hits_ping_url_for_start_state():
     ).never()
     hook_config = {'ping_url': 'https://example.com'}
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start', data=''.encode('utf-8'), verify=True
+        'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -100,7 +100,7 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
         'format_buffered_logs_for_payload'
     ).and_return(payload)
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com', data=payload.encode('utf-8'), verify=True
+        'https://example.com', data=payload.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -121,7 +121,7 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
         'format_buffered_logs_for_payload'
     ).and_return(payload)
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/fail', data=payload.encode('utf'), verify=True
+        'https://example.com/fail', data=payload.encode('utf'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -142,7 +142,7 @@ def test_ping_monitor_hits_ping_url_for_log_state():
         'format_buffered_logs_for_payload'
     ).and_return(payload)
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/log', data=payload.encode('utf'), verify=True
+        'https://example.com/log', data=payload.encode('utf'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -166,6 +166,7 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
         f"https://hc-ping.com/{hook_config['ping_url']}",
         data=payload.encode('utf-8'),
         verify=True,
+        timeout=int,
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -186,7 +187,7 @@ def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
         'format_buffered_logs_for_payload'
     ).and_return(payload)
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com', data=payload.encode('utf-8'), verify=False
+        'https://example.com', data=payload.encode('utf-8'), verify=False, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -207,7 +208,7 @@ def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
         'format_buffered_logs_for_payload'
     ).and_return(payload)
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com', data=payload.encode('utf-8'), verify=True
+        'https://example.com', data=payload.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -260,7 +261,7 @@ def test_ping_monitor_hits_ping_url_when_states_matching():
     ).never()
     hook_config = {'ping_url': 'https://example.com', 'states': ['start', 'finish']}
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start', data=''.encode('utf-8'), verify=True
+        'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -279,7 +280,7 @@ def test_ping_monitor_adds_create_query_parameter_when_create_slug_true():
     ).never()
     hook_config = {'ping_url': 'https://example.com', 'create_slug': True}
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True
+        'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -298,7 +299,7 @@ def test_ping_monitor_does_not_add_create_query_parameter_when_create_slug_false
     ).never()
     hook_config = {'ping_url': 'https://example.com', 'create_slug': False}
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start', data=''.encode('utf-8'), verify=True
+        'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -317,6 +318,7 @@ def test_ping_monitor_does_not_add_create_query_parameter_when_ping_url_is_uuid(
         f"https://hc-ping.com/{hook_config['ping_url']}",
         data=''.encode('utf-8'),
         verify=True,
+        timeout=int,
     ).and_return(flexmock(ok=True))
 
     module.ping_monitor(
@@ -352,7 +354,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
     ).never()
     hook_config = {'ping_url': 'https://example.com'}
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start', data=''.encode('utf-8'), verify=True
+        'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_raise(module.requests.exceptions.ConnectionError)
     flexmock(module.logger).should_receive('warning').once()
 
@@ -376,7 +378,7 @@ def test_ping_monitor_with_other_error_logs_warning():
         module.requests.exceptions.RequestException
     )
     flexmock(module.requests).should_receive('post').with_args(
-        'https://example.com/start', data=''.encode('utf-8'), verify=True
+        'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
 

+ 11 - 1
tests/unit/hooks/monitoring/test_ntfy.py

@@ -43,6 +43,7 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -67,6 +68,7 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -93,6 +95,7 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
 
@@ -119,6 +122,7 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -140,6 +144,7 @@ def test_ping_monitor_with_password_but_no_username_warns():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
 
@@ -162,6 +167,7 @@ def test_ping_monitor_with_username_but_no_password_warns():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
 
@@ -218,6 +224,7 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
         f'{custom_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -253,7 +260,7 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
         'resolve_credential'
     ).replace_with(lambda value, config: value)
     flexmock(module.requests).should_receive('post').with_args(
-        f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None
+        f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -275,6 +282,7 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.START),
         auth=None,
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -296,6 +304,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_raise(module.requests.exceptions.ConnectionError)
     flexmock(module.logger).should_receive('warning').once()
 
@@ -340,6 +349,7 @@ def test_ping_monitor_with_other_error_logs_warning():
         f'{default_base_url}/{topic}',
         headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
         auth=None,
+        timeout=int,
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
 

+ 10 - 0
tests/unit/hooks/monitoring/test_pushover.py

@@ -23,6 +23,7 @@ def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_
             'user': '983hfe0of902lkjfa2amanfgui',
             'message': 'fail',
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -81,6 +82,7 @@ def test_ping_monitor_start_state_backup_default_message_successfully_send_to_pu
             'user': '983hfe0of902lkjfa2amanfgui',
             'message': 'start',
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -117,6 +119,7 @@ def test_ping_monitor_start_state_backup_custom_message_successfully_send_to_pus
             'user': '983hfe0of902lkjfa2amanfgui',
             'message': 'custom start message',
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -155,6 +158,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
             'retry': 30,
             'expire': 600,
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -193,6 +197,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
             'retry': 30,
             'expire': 600,
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -231,6 +236,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
             'retry': 30,
             'expire': 600,
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -328,6 +334,7 @@ def test_ping_monitor_start_state_backup_based_on_documentation_advanced_example
             'html': 1,
             'ttl': 10,
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -399,6 +406,7 @@ def test_ping_monitor_fail_state_backup_based_on_documentation_advanced_example_
             'url': 'https://ticketing-system.example.com/login',
             'url_title': 'Login to ticketing system',
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -467,6 +475,7 @@ def test_ping_monitor_finish_state_backup_based_on_documentation_advanced_exampl
             'url': 'https://ticketing-system.example.com/login',
             'url_title': 'Login to ticketing system',
         },
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -550,6 +559,7 @@ def test_ping_monitor_push_post_error_bails():
             'user': '983hfe0of902lkjfa2amanfgui',
             'message': 'fail',
         },
+        timeout=int,
     ).and_return(push_response).once()
 
     flexmock(module.logger).should_receive('warning').once()

+ 4 - 2
tests/unit/hooks/monitoring/test_sentry.py

@@ -29,7 +29,8 @@ def test_ping_monitor_constructs_cron_url_and_pings_it(state, configured_states,
         hook_config['states'] = configured_states
 
     flexmock(module.requests).should_receive('post').with_args(
-        f'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status={expected_status}'
+        f'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status={expected_status}',
+        timeout=int,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -134,7 +135,8 @@ def test_ping_monitor_with_network_error_does_not_raise():
         module.requests.exceptions.ConnectionError
     )
     flexmock(module.requests).should_receive('post').with_args(
-        'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status=in_progress'
+        'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status=in_progress',
+        timeout=int,
     ).and_return(response).once()
 
     module.ping_monitor(

+ 8 - 8
tests/unit/hooks/monitoring/test_uptimekuma.py

@@ -10,7 +10,7 @@ CUSTOM_PUSH_URL = 'https://uptime.example.com/api/push/efgh5678'
 def test_ping_monitor_hits_default_uptimekuma_on_fail():
     hook_config = {}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{DEFAULT_PUSH_URL}?status=down&msg=fail', verify=True
+        f'{DEFAULT_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -26,7 +26,7 @@ def test_ping_monitor_hits_default_uptimekuma_on_fail():
 def test_ping_monitor_hits_custom_uptimekuma_on_fail():
     hook_config = {'push_url': CUSTOM_PUSH_URL}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -42,7 +42,7 @@ def test_ping_monitor_hits_custom_uptimekuma_on_fail():
 def test_ping_monitor_custom_uptimekuma_on_start():
     hook_config = {'push_url': CUSTOM_PUSH_URL}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=up&msg=start', verify=True
+        f'{CUSTOM_PUSH_URL}?status=up&msg=start', verify=True, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -58,7 +58,7 @@ def test_ping_monitor_custom_uptimekuma_on_start():
 def test_ping_monitor_custom_uptimekuma_on_finish():
     hook_config = {'push_url': CUSTOM_PUSH_URL}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=up&msg=finish', verify=True
+        f'{CUSTOM_PUSH_URL}?status=up&msg=finish', verify=True, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -116,7 +116,7 @@ def test_ping_monitor_does_not_hit_custom_uptimekuma_on_finish_dry_run():
 def test_ping_monitor_with_connection_error_logs_warning():
     hook_config = {'push_url': CUSTOM_PUSH_URL}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
     ).and_raise(module.requests.exceptions.ConnectionError)
     flexmock(module.logger).should_receive('warning').once()
 
@@ -137,7 +137,7 @@ def test_ping_monitor_with_other_error_logs_warning():
         module.requests.exceptions.RequestException
     )
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
 
@@ -168,7 +168,7 @@ def test_ping_monitor_with_invalid_run_state():
 def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
     hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': False}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=False
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=False, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
@@ -184,7 +184,7 @@ def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
 def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
     hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': True}
     flexmock(module.requests).should_receive('get').with_args(
-        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(

+ 5 - 5
tests/unit/hooks/monitoring/test_zabbix.py

@@ -74,7 +74,7 @@ def test_send_zabbix_request_with_post_error_bails():
     )
 
     flexmock(module.requests).should_receive('post').with_args(
-        server, headers=headers, json=data
+        server, headers=headers, json=data, timeout=int
     ).and_return(response)
 
     assert module.send_zabbix_request(server, headers, data) is None
@@ -89,7 +89,7 @@ def test_send_zabbix_request_with_invalid_json_response_bails():
     response.should_receive('json').and_raise(module.requests.exceptions.JSONDecodeError)
 
     flexmock(module.requests).should_receive('post').with_args(
-        server, headers=headers, json=data
+        server, headers=headers, json=data, timeout=int
     ).and_return(response)
 
     assert module.send_zabbix_request(server, headers, data) is None
@@ -103,7 +103,7 @@ def test_send_zabbix_request_with_success_returns_response_result():
     response.should_receive('json').and_return({'result': {'foo': 'bar'}})
 
     flexmock(module.requests).should_receive('post').with_args(
-        server, headers=headers, json=data
+        server, headers=headers, json=data, timeout=int
     ).and_return(response)
 
     assert module.send_zabbix_request(server, headers, data) == {'foo': 'bar'}
@@ -117,7 +117,7 @@ def test_send_zabbix_request_with_success_passes_through_missing_result():
     response.should_receive('json').and_return({})
 
     flexmock(module.requests).should_receive('post').with_args(
-        server, headers=headers, json=data
+        server, headers=headers, json=data, timeout=int
     ).and_return(response)
 
     assert module.send_zabbix_request(server, headers, data) is None
@@ -131,7 +131,7 @@ def test_send_zabbix_request_with_error_bails():
     response.should_receive('json').and_return({'result': {'data': [{'error': 'oops'}]}})
 
     flexmock(module.requests).should_receive('post').with_args(
-        server, headers=headers, json=data
+        server, headers=headers, json=data, timeout=int
     ).and_return(response)
 
     assert module.send_zabbix_request(server, headers, data) is None