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
49 changes: 25 additions & 24 deletions docs/source/configuration/remote_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,34 @@ Like other executors, the configuration uses unique identifiers (names) for each

.. code-block:: yaml

remote_config:
remote_server:
url: "https://10.0.0.5:5000"
username: admin
password: securepassword
cafile: "/path/to/cert.pem"
another_server:
url: "https://10.0.0.6:5000"
username: user
password: anotherpassword
cafile: "/path/to/another_cert.pem"
remote_config:
remote_server:
url: "https://10.0.0.5:5000"
username: admin
password: securepassword
cafile: "/path/to/cert.pem"
another_server:
url: "https://10.0.0.6:5000"
username: user
password: anotherpassword
cafile: "/path/to/another_cert.pem"


.. code-block:: yaml

commands:
# Executed on 'another_server'
- type: remote
connection: another_server
cmd: execute_command
remote_command:
type: shell
cmd: "whoami"

# Executed on 'remote_server' (defaults to first remote_config entry))
- type: remote
cmd: execute_playbook
playbook_yaml_path: path/to/playbook.yml
commands:
# Executed on 'another_server'
- type: remote
connection: another_server
cmd: execute_command
remote_command:
type: shell
cmd: "whoami"

# Executed on 'remote_server' (defaults to first remote_config entry))
- type: remote
cmd: execute_playbook
playbook_path: path/to/playbook.yml

.. confval:: url

Expand Down
1 change: 1 addition & 0 deletions docs/source/playbook/commands/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ The next pages will describe all possible commands in detail.
msf-session
payload
regex
remote
setvar
shell
sftp
Expand Down
127 changes: 127 additions & 0 deletions docs/source/playbook/commands/remote.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
======
remote
======

This command executes playbooks or commands on a remote AttackMate instance.
The connection to the remote instance is defined in the ``remote_config`` section of the configuration file.
If no connection is specified, the first entry in the ``remote_config`` section will be used as default.

Configuration
=============

Remote connections are defined under the ``remote_config`` key in the AttackMate configuration file.
Each entry requires at minimum a ``url`` a ``username``, ``password``, and ``cafile``
for TLS certificate verification.

.. code-block:: yaml

remote_config:
my-remote-instance:
url: https://192.42.0.254:8443
username: admin
password: secret
cafile: /path/to/ca.pem

Commands
========

Execute Command
---------------

Executes a single AttackMate command on the remote instance.

.. code-block:: yaml

commands:
- type: remote
cmd: execute_command
connection: my-remote-instance
remote_command:
type: shell
cmd: whoami

Execute Playbook
----------------

Sends a local playbook YAML file to the remote instance and executes it there.

.. code-block:: yaml

commands:
- type: remote
cmd: execute_playbook
connection: my-remote-instance
playbook_path: /path/to/playbook.yaml

Options
=======

.. confval:: cmd

The remote operation to perform. Must be one of the following:

- ``execute_command`` — Execute a single AttackMate command on the remote instance.
Requires ``remote_command`` to be set.
- ``execute_playbook`` — Execute a full playbook YAML file on the remote instance.
Requires ``playbook_path`` to be set.

:type: str (``execute_command`` | ``execute_playbook``)

.. confval:: connection

The name of the remote connection to use, as defined in the ``remote_config`` section
of the configuration file. If omitted, the first entry in ``remote_config`` is used
as the default connection.

:type: str
:default: first entry in ``remote_config``

.. confval:: playbook_path

Path to a local YAML playbook file that will be read and sent to the remote AttackMate
instance for execution. Required when ``cmd`` is ``execute_playbook``.

:type: str

.. confval:: remote_command

An inline AttackMate command definition that will be executed on the remote instance.
This supports any command type that the remote AttackMate instance is configured to handle
(e.g., ``shell``, ``sliver``, etc., EXCEPT another remote_command). Required when ``cmd`` is ``execute_command``.

:type: RemotelyExecutableCommand

.. warning::

Parameters such as ``background`` and ``only_if`` defined at the top-level ``remote`` command
apply to the **local** execution of the remote command, not to the command executed on the
remote instance.

Examples
========

Execute a shell command on a remote instance
--------------------------------------------

.. code-block:: yaml

vars:
$REMOTE_HOST: my-remote-instance

commands:
- type: remote
cmd: execute_command
connection: $REMOTE_HOST
remote_command:
type: shell
cmd: id

Execute a playbook on a remote instance using the default connection
--------------------------------------------------------------------

.. code-block:: yaml

commands:
- type: remote
cmd: execute_playbook
playbook_path: /homeuser/playbooks/recon.yaml
50 changes: 0 additions & 50 deletions docs/source/remote.rst

This file was deleted.

8 changes: 4 additions & 4 deletions src/attackmate/executors/remote/remoteexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,15 @@ def _dispatch_remote_command(
debug = getattr(command, 'debug', False)
self.logger.debug(f"Dispatching command '{command.cmd}' with debug={debug}")

if command.cmd == 'execute_playbook' and command.playbook_yaml_path:
if command.cmd == 'execute_playbook' and command.playbook_path:
try:
with open(command.playbook_yaml_path, 'r', encoding='utf-8') as f:
with open(command.playbook_path, 'r', encoding='utf-8') as f:
yaml_content = f.read()
response = client.execute_remote_playbook_yaml(yaml_content, debug=debug)
except FileNotFoundError as e:
raise ExecException(f'Playbook file not found: {command.playbook_yaml_path}') from e
raise ExecException(f'Playbook file not found: {command.playbook_path}') from e
except IOError as e:
raise ExecException(f'Failed to read playbook file {command.playbook_yaml_path}: {e}') from e
raise ExecException(f'Failed to read playbook file {command.playbook_path}: {e}') from e

elif command.cmd == 'execute_command':
response = client.execute_remote_command(command.remote_command, debug=debug)
Expand Down
6 changes: 3 additions & 3 deletions src/attackmate/schemas/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AttackMateRemoteCommand(BaseCommand):
type: Literal['remote']
cmd: Literal['execute_command', 'execute_playbook']
connection: Optional[str] = None
playbook_yaml_path: Optional[str] = None
playbook_path: Optional[str] = None
remote_command: Optional[RemotelyExecutableCommand] = None

# Common command parameters (like background, only_if) from BaseCommand
Expand All @@ -23,6 +23,6 @@ class AttackMateRemoteCommand(BaseCommand):
def check_remote_command(self) -> 'AttackMateRemoteCommand':
if self.cmd == 'execute_command' and not self.remote_command:
raise ValueError("remote_command must be provided when cmd is 'execute_command'")
if self.cmd == 'execute_playbook' and not self.playbook_yaml_path:
raise ValueError("playbook_yaml_path must be provided when cmd is 'execute_playbook_yaml'")
if self.cmd == 'execute_playbook' and not self.playbook_path:
raise ValueError("playbook_path must be provided when cmd is 'execute_playbook_yaml'")
return self
8 changes: 4 additions & 4 deletions test/units/test_remoteexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def mock_remote_command():
command.type = 'remote'
command.remote_command = MagicMock()
command.remote_command.model_dump = MagicMock(return_value={'cmd': 'test'})
command.playbook_yaml_path = None
command.playbook_path = None
command.debug = False
return command

Expand Down Expand Up @@ -227,7 +227,7 @@ def test_dispatch_execute_playbook(self, setup_executor, mock_remote_command, te
mock_client = MagicMock()
mock_client.execute_remote_playbook_yaml = MagicMock(return_value={'success': True})
mock_remote_command.cmd = 'execute_playbook'
mock_remote_command.playbook_yaml_path = temp_yaml_file
mock_remote_command.playbook_path = temp_yaml_file

response = executor._dispatch_remote_command(mock_client, mock_remote_command)

Expand All @@ -242,7 +242,7 @@ def test_dispatch_execute_playbook_file_not_found(self, setup_executor, mock_rem
executor = setup_executor
mock_client = MagicMock()
mock_remote_command.cmd = 'execute_playbook'
mock_remote_command.playbook_yaml_path = '/nonexistent/file.yaml'
mock_remote_command.playbook_path = '/nonexistent/file.yaml'

with pytest.raises(ExecException, match='Playbook file not found'):
executor._dispatch_remote_command(mock_client, mock_remote_command)
Expand All @@ -252,7 +252,7 @@ def test_dispatch_execute_playbook_read_error(self, setup_executor, mock_remote_
executor = setup_executor
mock_client = MagicMock()
mock_remote_command.cmd = 'execute_playbook'
mock_remote_command.playbook_yaml_path = '/path/to/file.yaml'
mock_remote_command.playbook_path = '/path/to/file.yaml'

with patch('builtins.open', side_effect=IOError('Permission denied')):
with pytest.raises(ExecException, match='Failed to read playbook file'):
Expand Down