diff --git a/docs/source/configuration/command_config.rst b/docs/source/configuration/command_config.rst index a0fb8d0ff..8091cf34d 100644 --- a/docs/source/configuration/command_config.rst +++ b/docs/source/configuration/command_config.rst @@ -10,6 +10,9 @@ These are settings for **all** commands. cmd_config: loop_sleep: 5 command_delay: 0 + command_delay_jitter: false + command_delay_jitter_min: 0.5 + command_delay_jitter_max: 2.0 .. confval:: loop_sleep @@ -27,3 +30,29 @@ These are settings for **all** commands. :type: float :default: 0 + +.. confval:: command_delay_jitter + + When ``true``, randomizes the per-command delay. The effective delay is calculated as + ``command_delay ± random(command_delay_jitter_min, command_delay_jitter_max)``, + clamped to a minimum of ``0``. Has no effect when ``command_delay_jitter`` + is ``false``. + + :type: bool + :default: false + +.. confval:: command_delay_jitter_min + + Lower bound of the jitter offset in seconds. Only used when + :confval:`command_delay_jitter` is ``true``. + + :type: float + :default: 0.5 + +.. confval:: command_delay_jitter_max + + Upper bound of the jitter offset in seconds. Only used when + :confval:`command_delay_jitter` is ``true``. + + :type: float + :default: 2.0 diff --git a/docs/source/configuration/index.rst b/docs/source/configuration/index.rst index 7f20365a2..ebdb3617b 100644 --- a/docs/source/configuration/index.rst +++ b/docs/source/configuration/index.rst @@ -26,7 +26,10 @@ sliver, metasploit and remote attackmate server: cmd_config: loop_sleep: 5 - command_delay: 0 + command_delay: 1 + command_delay_jitter: true + command_delay_jitter_min: 0.5 + command_delay_jitter_max: 2.0 bettercap_config: default: diff --git a/src/attackmate/attackmate.py b/src/attackmate/attackmate.py index 8c900dae5..52d6f96bb 100644 --- a/src/attackmate/attackmate.py +++ b/src/attackmate/attackmate.py @@ -14,6 +14,7 @@ :ref:`integration` for documentation on scripted usage. """ +import random import time from typing import Dict, Optional import logging @@ -150,14 +151,27 @@ async def _run_commands(self, commands: Commands): .. note:: The delay between commands is controlled by ``cmd_config.command_delay`` in the :class:`Config`. Defaults to ``0`` if not set. + + When ``cmd_config.command_delay_jitter`` is ``True``, each delay is + randomized as ``command_delay ± random(jitter_min, jitter_max)``, + clamped to a minimum of ``0``. The jitter bounds are set via + ``command_delay_jitter_min`` (default ``0.5``) and + ``command_delay_jitter_max`` (default ``2.0``). """ - delay = self.pyconfig.cmd_config.command_delay or 0 - self.logger.info(f'Delay before commands: {delay} seconds') + cfg = self.pyconfig.cmd_config + base_delay = cfg.command_delay or 0 for command in commands: command_type = 'ssh' if command.type == 'sftp' else command.type executor = self._get_executor(command_type) if executor: if command.type not in ('sleep', 'debug', 'setvar'): + if cfg.command_delay_jitter: + offset = random.uniform(cfg.command_delay_jitter_min, cfg.command_delay_jitter_max) + sign = random.choice([-1, 1]) + delay = max(0.0, base_delay + sign * offset) + else: + delay = base_delay + self.logger.info(f'Delay before command: {delay:.3f} seconds') time.sleep(delay) await executor.run(command, is_api_instance=self.is_api_instance) diff --git a/src/attackmate/schemas/config.py b/src/attackmate/schemas/config.py index 4c785c96d..33ba7455d 100644 --- a/src/attackmate/schemas/config.py +++ b/src/attackmate/schemas/config.py @@ -17,6 +17,9 @@ class MsfConfig(BaseModel): class CommandConfig(BaseModel): loop_sleep: int = 5 command_delay: float = 0 + command_delay_jitter: bool = False + command_delay_jitter_min: float = 0.5 + command_delay_jitter_max: float = 2.0 class BettercapConfig(BaseModel): diff --git a/test/units/test_commanddelay.py b/test/units/test_commanddelay.py index 2295402c6..e08fdb620 100644 --- a/test/units/test_commanddelay.py +++ b/test/units/test_commanddelay.py @@ -1,4 +1,5 @@ import time +from unittest.mock import patch import pytest from attackmate.attackmate import AttackMate from attackmate.schemas.config import Config, CommandConfig @@ -93,3 +94,65 @@ async def test_delay_is_not_applied_for_exempt_commands(): assert elapsed_time < 0.1, ( f'Execution with exempt commands took too long: {elapsed_time:.4f}s.' ) + + +@pytest.mark.asyncio +async def test_jitter_off_behavior_unchanged(): + """ + With jitter disabled (default), behavior is identical to fixed command_delay. + """ + delay = 0.1 + playbook = Playbook(commands=[ShellCommand(type='shell', cmd='echo 1')]) + config = Config(cmd_config=CommandConfig(command_delay=delay, command_delay_jitter=False)) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + with patch('attackmate.attackmate.time.sleep') as mock_sleep: + await attackmate_instance._run_commands(attackmate_instance.playbook.commands) + mock_sleep.assert_called_once_with(delay) + + +@pytest.mark.asyncio +async def test_jitter_on_computes_correct_delay(): + """ + With jitter enabled, actual delay = command_delay + sign * uniform(jitter_min, jitter_max), + clamped to >= 0. + """ + playbook = Playbook(commands=[ShellCommand(type='shell', cmd='echo 1')]) + config = Config(cmd_config=CommandConfig( + command_delay=1.0, + command_delay_jitter=True, + command_delay_jitter_min=0.5, + command_delay_jitter_max=2.0, + )) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + with patch('attackmate.attackmate.random.uniform', return_value=0.8) as mock_uniform, \ + patch('attackmate.attackmate.random.choice', return_value=1) as mock_choice, \ + patch('attackmate.attackmate.time.sleep') as mock_sleep: + await attackmate_instance._run_commands(attackmate_instance.playbook.commands) + mock_uniform.assert_called_once_with(0.5, 2.0) + mock_choice.assert_called_once_with([-1, 1]) + # 1.0 + 1 * 0.8 = 1.8, clamped to >= 0 → 1.8 + mock_sleep.assert_called_once_with(1.8) + + +@pytest.mark.asyncio +async def test_jitter_clamped_to_zero(): + """ + When jitter produces a negative effective delay, it is clamped to 0. + """ + playbook = Playbook(commands=[ShellCommand(type='shell', cmd='echo 1')]) + config = Config(cmd_config=CommandConfig( + command_delay=0.0, + command_delay_jitter=True, + command_delay_jitter_min=0.5, + command_delay_jitter_max=2.0, + )) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + with patch('attackmate.attackmate.random.uniform', return_value=1.5), \ + patch('attackmate.attackmate.random.choice', return_value=-1), \ + patch('attackmate.attackmate.time.sleep') as mock_sleep: + await attackmate_instance._run_commands(attackmate_instance.playbook.commands) + # 0.0 + (-1) * 1.5 = -1.5, clamped → 0.0 + mock_sleep.assert_called_once_with(0.0)