Skip to content
Merged
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
29 changes: 29 additions & 0 deletions docs/source/configuration/command_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
5 changes: 4 additions & 1 deletion docs/source/configuration/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions src/attackmate/attackmate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
:ref:`integration` for documentation on scripted usage.
"""

import random
import time
from typing import Dict, Optional
import logging
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/attackmate/schemas/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
63 changes: 63 additions & 0 deletions test/units/test_commanddelay.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading