diff --git a/.github/ISSUE_TEMPLATE/01_bug_report.md b/.github/ISSUE_TEMPLATE/01_bug_report.md index a258c9501..42cde331c 100644 --- a/.github/ISSUE_TEMPLATE/01_bug_report.md +++ b/.github/ISSUE_TEMPLATE/01_bug_report.md @@ -7,15 +7,33 @@ about: If something isn't working 🔧 Describe your issue here. ### Your environment -* Version of ruby -* Docker or manual installation? -* Which browser and its version +* Version of AttackMate (`attackmate --version`) +* Python version (`python --version`) +* Ansible, Docker, pip, or uv installation? +* OS and version ### Steps to reproduce -Tell us how to reproduce this issue. +Tell us how to reproduce this issue. + +### Playbook +Provide a minimal playbook that reproduces the issue (remove sensitive data): + +```yaml + +``` + +### Config +Provide the config you used. + +```yaml + +``` ### Expected behaviour Tell us what should happen ### Actual behaviour Tell us what happens instead + +### Log output +Paste the relevant log output or error/stack trace here: diff --git a/.github/ISSUE_TEMPLATE/02_feature_request.md b/.github/ISSUE_TEMPLATE/02_feature_request.md index a7edab580..979fae026 100644 --- a/.github/ISSUE_TEMPLATE/02_feature_request.md +++ b/.github/ISSUE_TEMPLATE/02_feature_request.md @@ -5,7 +5,7 @@ about: If you have a feature request 💡 **Context** -What are you trying to do and how would you want to do it differently? Is it something you currently you cannot do? Is this related to an issue/problem? +What are you trying to do and how would you want to do it differently? Is it something you currently cannot do? Is this related to an issue/problem? **Alternatives** @@ -18,4 +18,3 @@ Please provide a link to the issue. **If the feature request is approved, would you be willing to submit a PR?** Yes / No _(Help can be provided if you need assistance submitting a PR)_ - 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 a5d5460b4..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: @@ -35,11 +38,13 @@ sliver, metasploit and remote attackmate server: password: password msf_config: - password: securepassword - server: 127.0.0.1 + default: + password: securepassword + server: 127.0.0.1 sliver_config: - config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg + default: + config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg remote_config: remote_server_name: diff --git a/docs/source/configuration/msf_config.rst b/docs/source/configuration/msf_config.rst index afddee4a0..67caefb2d 100644 --- a/docs/source/configuration/msf_config.rst +++ b/docs/source/configuration/msf_config.rst @@ -4,13 +4,40 @@ msf_config ========== -``msf_config`` holds connection settings for the Metasploit RPC daemon (``msfrpcd``). +``msf_config`` holds connection settings for one or more Metasploit RPC daemon (``msfrpcd``) +instances. Each connection is identified by a name. The first defined connection is used by +default when a command does not specify one explicitly. .. code-block:: yaml msf_config: - password: securepassword - server: 10.18.3.86 + primary: + password: securepassword + server: 10.18.3.86 + +Multiple servers can be defined and selected per command via the :confval:`connection` field: + +.. code-block:: yaml + + msf_config: + server1: + password: securepassword + server: 10.18.3.86 + server2: + password: anothersecret + server: 10.18.3.87 + +.. note:: + + **Backwards compatibility:** The legacy single-server format is still accepted and is + automatically migrated to a named entry called ``default``: + + .. code-block:: yaml + + # Legacy format (still supported) + msf_config: + password: securepassword + server: 10.18.3.86 .. confval:: server diff --git a/docs/source/configuration/sliver_config.rst b/docs/source/configuration/sliver_config.rst index e0c356f14..7b8612009 100644 --- a/docs/source/configuration/sliver_config.rst +++ b/docs/source/configuration/sliver_config.rst @@ -4,12 +4,37 @@ sliver_config ============= -``sliver_config`` stores all settings to control the connection to the Sliver API. +``sliver_config`` stores connection settings for one or more Sliver C2 servers. +Each connection is identified by a name. The first defined connection is used by +default when a command does not specify one explicitly. .. code-block:: yaml sliver_config: - config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg + primary: + config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg + +Multiple servers can be defined and selected per command via the :confval:`connection` field: + +.. code-block:: yaml + + sliver_config: + sliver_server1: + config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg + sliver_server2: + config_file: /home/attacker/.sliver-client/configs/attacker2_localhost.cfg + +.. note:: + + **Backwards compatibility:** The legacy single-server format is still accepted and is + automatically migrated to a named entry called ``default``: + + .. code-block:: yaml + + # Legacy format (still supported) + sliver_config: + config_file: /home/attacker/.sliver-client/configs/attacker_localhost.cfg + .. confval:: config_file diff --git a/docs/source/developing/command.rst b/docs/source/developing/command.rst index 9896d4a3b..53c9db262 100644 --- a/docs/source/developing/command.rst +++ b/docs/source/developing/command.rst @@ -110,7 +110,7 @@ The factory filters the provided configurations based on the class constructor s 'pm': self.pm, 'varstore': self.varstore, 'cmdconfig': self.pyconfig.cmd_config, - 'msfconfig': self.pyconfig.msf_config, + 'msf_config': self.pyconfig.msf_config, 'msfsessionstore': self.msfsessionstore, 'sliver_config': self.pyconfig.sliver_config, 'runfunc': self._run_commands, diff --git a/docs/source/developing/integration.rst b/docs/source/developing/integration.rst index 1ba519e7a..df66256a1 100644 --- a/docs/source/developing/integration.rst +++ b/docs/source/developing/integration.rst @@ -247,11 +247,11 @@ Top-level configuration object. All fields have safe defaults, so an empty - ``CommandConfig`` - Global command execution settings. See below. * - ``msf_config`` - - ``MsfConfig`` - - Metasploit RPC connection settings. See below. + - ``dict[str, MsfConfig]`` + - Named Metasploit RPC connections. See below. * - ``sliver_config`` - - ``SliverConfig`` - - Sliver C2 connection settings. See below. + - ``dict[str, SliverConfig]`` + - Named Sliver C2 connections. See below. * - ``bettercap_config`` - ``dict[str, BettercapConfig]`` - Named Bettercap instances. Keys are referenced by ``webserv`` commands. @@ -440,7 +440,7 @@ Running a Single Command initialize_logger(debug=False, append_logs=False) config = Config( - msf_config={"password": "your_password", "ssl": True, "port": 55553}, + msf_config={"default": {"password": "your_password", "ssl": True, "port": 55553}}, cmd_config={"loop_sleep": 10}, ) varstore = {"HOST": "10.0.0.1"} diff --git a/docs/source/playbook/commands/msf-module.rst b/docs/source/playbook/commands/msf-module.rst index 2fd2f4746..2bc597cb3 100644 --- a/docs/source/playbook/commands/msf-module.rst +++ b/docs/source/playbook/commands/msf-module.rst @@ -89,7 +89,14 @@ Most exploit modules do not produce direct output but instead open a session :type: str - The following example illustrates the use of sessions and payloads: +.. confval:: connection + + Name of the MSF server connection to use, as defined in :ref:`msf_config`. + If omitted, the first configured connection is used. + + :type: str + +The following example illustrates the use of sessions and payloads: .. code-block:: yaml diff --git a/docs/source/playbook/commands/msf-session.rst b/docs/source/playbook/commands/msf-session.rst index ed18e2fb4..2c5b94cca 100644 --- a/docs/source/playbook/commands/msf-session.rst +++ b/docs/source/playbook/commands/msf-session.rst @@ -78,3 +78,10 @@ Execute commands in an active Meterpreter session previously opened by an String indicating the end of a read-operation. :type: str + +.. confval:: connection + + Name of the MSF server connection to use, as defined in :ref:`msf_config`. + If omitted, the first configured connection is used. + + :type: str diff --git a/docs/source/playbook/commands/payload.rst b/docs/source/playbook/commands/payload.rst index 7a2ee0736..66dabeb25 100644 --- a/docs/source/playbook/commands/payload.rst +++ b/docs/source/playbook/commands/payload.rst @@ -100,3 +100,10 @@ Generate a Metasploit payload and save it to a file. :type: int :default: ``0`` + +.. confval:: connection + + Name of the MSF server connection to use, as defined in :ref:`msf_config`. + If omitted, the first configured connection is used. + + :type: str diff --git a/docs/source/playbook/commands/sliver-session.rst b/docs/source/playbook/commands/sliver-session.rst index 2ff9d0d00..3f9602a61 100644 --- a/docs/source/playbook/commands/sliver-session.rst +++ b/docs/source/playbook/commands/sliver-session.rst @@ -9,6 +9,8 @@ Execute commands within an active Sliver implant session. All commands require a .. note:: + To configure the connection to the Sliver server, see :ref:`sliver_config`. + **For developers:** The ``sliver`` and ``sliver-session`` command families use a legacy ``type`` + ``cmd`` discrimination pattern and should not be replicated. New commands must define a unique ``type`` literal and handle sub-behavior branching via ``cmd`` @@ -22,6 +24,13 @@ Execute commands within an active Sliver implant session. All commands require a :type: str :required: True +.. confval:: connection + + Name of the Sliver server connection to use, as defined in :ref:`sliver_config`. + If omitted, the first configured connection is used. + + :type: str + File System ----------- diff --git a/docs/source/playbook/commands/sliver.rst b/docs/source/playbook/commands/sliver.rst index 8a8165816..f37cc6b6f 100644 --- a/docs/source/playbook/commands/sliver.rst +++ b/docs/source/playbook/commands/sliver.rst @@ -8,11 +8,20 @@ Control the Sliver C2 server via its API. All commands use ``type: sliver``. .. note:: + To configure the connection to the Sliver server, see :ref:`sliver_config`. + **For developers:** The ``sliver`` and ``sliver-session`` command families use a legacy ``type`` + ``cmd`` discrimination pattern and should not be replicated. New commands must define a unique ``type`` literal and handle sub-behavior branching via ``cmd`` in the executor. See :ref:`command` for details. +.. confval:: connection + + Name of the Sliver server connection to use, as defined in :ref:`sliver_config`. + If omitted, the first configured connection is used. + + :type: str + start_https_listener -------------------- diff --git a/pyproject.toml b/pyproject.toml index 98ce574e2..696572d5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,13 @@ dependencies = [ "dotenv", "passlib", "python-multipart", - "attackmate-client @ git+https://github.com/ait-testbed/attackmate-client.git", + "attackmate-client>=0.1.2", ] dynamic = ["version"] [project.scripts] attackmate = "attackmate.__main__:main" +attackm8 = "attackmate.__main__:main" [build-system] requires = ["setuptools>=61.0.0", "wheel"] diff --git a/src/attackmate/attackmate.py b/src/attackmate/attackmate.py index 292a94a88..52d6f96bb 100644 --- a/src/attackmate/attackmate.py +++ b/src/attackmate/attackmate.py @@ -14,12 +14,13 @@ :ref:`integration` for documentation on scripted usage. """ +import random import time from typing import Dict, Optional import logging from attackmate.result import Result import attackmate.executors as executors -from attackmate.schemas.config import CommandConfig, Config, MsfConfig, SliverConfig +from attackmate.schemas.config import CommandConfig, Config from attackmate.schemas.playbook import Playbook from attackmate.schemas.command_types import Commands, Command from attackmate.variablestore import VariableStore @@ -87,8 +88,8 @@ def _default_playbook(self) -> Playbook: def _default_config(self) -> Config: return Config( cmd_config=CommandConfig(), - msf_config=MsfConfig(), - sliver_config=SliverConfig(), + msf_config={}, + sliver_config={}, bettercap_config={}, remote_config={} ) @@ -109,7 +110,7 @@ def _get_executor_config(self) -> dict: 'pm': self.pm, 'varstore': self.varstore, 'cmdconfig': self.pyconfig.cmd_config, - 'msfconfig': self.pyconfig.msf_config, + 'msf_config': self.pyconfig.msf_config, 'bettercap_config': self.pyconfig.bettercap_config, 'remote_config': self.pyconfig.remote_config, 'msfsessionstore': self.msfsessionstore, @@ -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/executors/__init__.py b/src/attackmate/executors/__init__.py index 53506040b..a4b58735c 100644 --- a/src/attackmate/executors/__init__.py +++ b/src/attackmate/executors/__init__.py @@ -23,7 +23,7 @@ from .browser.browserexecutor import BrowserExecutor __all__ = [ - 'RemoteExecutor' + 'RemoteExecutor', 'BrowserExecutor', 'ShellExecutor', 'SSHExecutor', diff --git a/src/attackmate/executors/metasploit/msfclientmixin.py b/src/attackmate/executors/metasploit/msfclientmixin.py new file mode 100644 index 000000000..cb33c21f0 --- /dev/null +++ b/src/attackmate/executors/metasploit/msfclientmixin.py @@ -0,0 +1,40 @@ +import logging +from typing import Dict +from pymetasploit3.msfrpc import MsfRpcClient, MsfAuthError +from attackmate.schemas.base import BaseCommand +from attackmate.schemas.config import MsfConfig +from attackmate.execexception import ExecException + + +class MsfClientMixin: + msf_config: Dict[str, MsfConfig] + _msf_clients: Dict[str, MsfRpcClient] + logger: logging.Logger + + def _resolve_connection(self, command: BaseCommand) -> str: + conn = getattr(command, 'connection', None) + if conn: + return conn + if self.msf_config: + conn_name = next(iter(self.msf_config)) + self.logger.debug(f'No connection specified, using default: {conn_name}') + return conn_name + raise ExecException('No MSF connections configured') + + def _get_client(self, conn_name: str) -> MsfRpcClient: + if conn_name not in self._msf_clients: + if conn_name not in self.msf_config: + raise ExecException(f"MSF connection '{conn_name}' not found in config") + config = self.msf_config[conn_name] + self.logger.debug( + f"Connecting to MSF server '{conn_name}' at {config.server}:{config.port} (ssl={config.ssl})" + ) + try: + self._msf_clients[conn_name] = MsfRpcClient(**config.model_dump()) + except IOError as e: + self.logger.error(e) + raise ExecException(f'MSF connection failed: {e}') + except MsfAuthError as e: + self.logger.error(e) + raise ExecException(f'MSF authentication failed: {e}') + return self._msf_clients[conn_name] diff --git a/src/attackmate/executors/metasploit/msfexecutor.py b/src/attackmate/executors/metasploit/msfexecutor.py index 3d020542a..7be530f75 100644 --- a/src/attackmate/executors/metasploit/msfexecutor.py +++ b/src/attackmate/executors/metasploit/msfexecutor.py @@ -1,6 +1,4 @@ -from pymetasploit3.msfrpc import MsfRpcClient, MsfAuthError - -from typing import Optional +from typing import Optional, Dict from attackmate.variablestore import VariableStore from attackmate.executors.baseexecutor import BaseExecutor from attackmate.execexception import ExecException @@ -8,28 +6,31 @@ from attackmate.executors.features.cmdvars import CmdVars from attackmate.schemas.base import BaseCommand from attackmate.schemas.metasploit import MsfModuleCommand +from attackmate.schemas.config import MsfConfig from attackmate.executors.metasploit.msfsessionstore import MsfSessionStore +from attackmate.executors.metasploit.msfclientmixin import MsfClientMixin from attackmate.processmanager import ProcessManager from multiprocessing.managers import BaseManager from multiprocessing import Manager from multiprocessing.queues import JoinableQueue from attackmate.executors.executor_factory import executor_factory +from pymetasploit3.msfrpc import MsfRpcClient @executor_factory.register_executor('msf-module') -class MsfModuleExecutor(BaseExecutor): +class MsfModuleExecutor(MsfClientMixin, BaseExecutor): def __init__( self, pm: ProcessManager, cmdconfig=None, *, varstore: VariableStore, - msfconfig=None, + msf_config: Dict[str, MsfConfig] = {}, msfsessionstore: MsfSessionStore, ): - self.msfconfig = msfconfig + self.msf_config = msf_config + self._msf_clients: Dict[str, MsfRpcClient] = {} self.sessionstore = msfsessionstore - self.msf = None self.manager: Optional[BaseManager] = None super().__init__(pm, varstore, cmdconfig) @@ -41,33 +42,17 @@ def _create_queue(self) -> Optional[JoinableQueue]: self.sessionstore.queue = self.manager.JoinableQueue() # type: ignore return self.sessionstore.queue - def connect(self, msfconfig=None): - try: - self.msf = MsfRpcClient(**msfconfig.dict()) - except IOError as e: - self.logger.error(e) - self.msf = None - except MsfAuthError as e: - self.logger.error(e) - self.msf = None - def log_command(self, command: BaseCommand): - if self.msf is None: - self.logger.debug('Connecting to msf-server...') - self.connect(self.msfconfig) self.logger.info(f"Executing Msf-Module: '{command.cmd}'") - def prepare_payload(self, command: MsfModuleCommand): + def prepare_payload(self, command: MsfModuleCommand, msf: MsfRpcClient): self.logger.debug(f'Using payload: {command.payload}') if command.payload is None: return None - if self.msf is not None: - try: - payload = self.msf.modules.use('payload', command.payload) - except TypeError: - raise ExecException(f'Payload {command.payload} seems to be incorrect') - else: - raise ExecException('Problems with the metasploit connection') + try: + payload = msf.modules.use('payload', command.payload) + except TypeError: + raise ExecException(f'Payload {command.payload} seems to be incorrect') for option, setting in command.payload_options.items(): try: payload[option] = setting @@ -76,16 +61,13 @@ def prepare_payload(self, command: MsfModuleCommand): self.logger.debug(payload.options) return payload - def prepare_exploit(self, command: MsfModuleCommand): + def prepare_exploit(self, command: MsfModuleCommand, msf: MsfRpcClient, conn_name: str): exploit = None option: str = '' try: self.logger.debug(f'module_type: {command.module_type()}') self.logger.debug(f'module_path: {command.module_path()}') - if self.msf is not None: - exploit = self.msf.modules.use(command.module_type(), command.module_path()) - else: - raise ExecException('Problems with the metasploit connection') + exploit = msf.modules.use(command.module_type(), command.module_path()) self.logger.debug(exploit.description) for option, setting in command.options.items(): if setting.isnumeric(): @@ -96,7 +78,7 @@ def prepare_exploit(self, command: MsfModuleCommand): else: exploit[option] = setting if command.session: - session_id = self.sessionstore.get_session_by_name(command.session, self.msf.sessions) + session_id = self.sessionstore.get_session_by_name(conn_name, command.session, msf.sessions) self.logger.debug(f'Using session-id: {session_id}') exploit['SESSION'] = int(session_id) except KeyError: @@ -105,16 +87,15 @@ def prepare_exploit(self, command: MsfModuleCommand): raise ExecException(f'Module or Module Type is Unknown: {e}') if exploit.missing_required: raise ExecException(f'Missing required exploit options: {exploit.missing_required}') - exploit.target = CmdVars.variable_to_int('target', command.target) return exploit async def _exec_cmd(self, command: MsfModuleCommand) -> Result: - if self.msf is None: - raise ExecException('ConnectionError') + conn_name = self._resolve_connection(command) + msf = self._get_client(conn_name) - exploit = self.prepare_exploit(command) - payload = self.prepare_payload(command) + exploit = self.prepare_exploit(command, msf, conn_name) + payload = self.prepare_payload(command, msf) if command.creates_session is not None: self.logger.debug('Command creates a msf-session') @@ -124,28 +105,28 @@ async def _exec_cmd(self, command: MsfModuleCommand) -> Result: if command.module_path() == 'multi/manage/shell_to_meterpreter': self.logger.debug('Waiting for increased session..') self.sessionstore.wait_for_increased_session( - command.creates_session, result['uuid'], self.msf.sessions, self.child_queue + conn_name, command.creates_session, result['uuid'], msf.sessions, self.child_queue ) else: self.sessionstore.wait_for_session( - command.creates_session, result['uuid'], self.msf.sessions, self.child_queue + conn_name, command.creates_session, result['uuid'], msf.sessions, self.child_queue ) return Result('', 0) - cid = self.msf.consoles.console().cid - output = self.msf.consoles.console(cid).run_module_with_output(exploit, payload=payload) + cid = msf.consoles.console().cid + output = msf.consoles.console(cid).run_module_with_output(exploit, payload=payload) return Result(output, 0) def cleanup(self): - if self.msf is not None: - self.logger.debug('Killing all Meterpreter sessions') - active_sessions = self.msf.sessions.list + for conn_name, msf in self._msf_clients.items(): + self.logger.debug(f'Killing all Meterpreter sessions for connection: {conn_name}') + active_sessions = msf.sessions.list if active_sessions: for session_id, session_data in active_sessions.items(): try: self.logger.debug(f'Stopping msf session {session_id}') - self.msf.sessions.session(session_id).stop() + msf.sessions.session(session_id).stop() self.logger.info(f'Msf session {session_id} stopped successfully.') except Exception as e: self.logger.error(f'Failed to stop msf session {session_id}: {str(e)}') else: - self.logger.debug('No active msf sessions found.') + self.logger.debug(f'No active msf sessions found for connection: {conn_name}') diff --git a/src/attackmate/executors/metasploit/msfpayloadexecutor.py b/src/attackmate/executors/metasploit/msfpayloadexecutor.py index 18e1c5207..dc8e4737f 100644 --- a/src/attackmate/executors/metasploit/msfpayloadexecutor.py +++ b/src/attackmate/executors/metasploit/msfpayloadexecutor.py @@ -6,8 +6,8 @@ """ import tempfile -from typing import Any -from pymetasploit3.msfrpc import MsfRpcClient, MsfAuthError +from typing import Any, Dict +from pymetasploit3.msfrpc import MsfRpcClient from attackmate.executors.baseexecutor import BaseExecutor from attackmate.result import Result from attackmate.execexception import ExecException @@ -15,40 +15,27 @@ from attackmate.variablestore import VariableStore from attackmate.executors.features.cmdvars import CmdVars from attackmate.schemas.metasploit import MsfPayloadCommand -from attackmate.schemas.config import CommandConfig +from attackmate.schemas.config import CommandConfig, MsfConfig +from attackmate.executors.metasploit.msfclientmixin import MsfClientMixin from attackmate.executors.executor_factory import executor_factory @executor_factory.register_executor('msf-payload') -class MsfPayloadExecutor(BaseExecutor): +class MsfPayloadExecutor(MsfClientMixin, BaseExecutor): def __init__( - self, pm: ProcessManager, varstore: VariableStore, cmdconfig=CommandConfig(), *, msfconfig=None + self, pm: ProcessManager, varstore: VariableStore, cmdconfig=CommandConfig(), *, + msf_config: Dict[str, MsfConfig] = {} ): - self.msfconfig = msfconfig - self.msf = None + self.msf_config = msf_config + self._msf_clients: Dict[str, MsfRpcClient] = {} self.tempfilestore: list[Any] = [] super().__init__(pm, varstore, cmdconfig) - def connect(self, msfconfig=None): - try: - self.msf = MsfRpcClient(**msfconfig.dict()) - except IOError as e: - self.logger.error(e) - self.msf = None - except MsfAuthError as e: - self.logger.error(e) - self.msf = None - def log_command(self, command: MsfPayloadCommand): - if self.msf is None: - self.logger.debug('Connecting to msf-server...') - self.connect(self.msfconfig) self.logger.info(f"Generating Msf-Payload: '{command.cmd}'") - def prepare_payload(self, command: MsfPayloadCommand): - if not self.msf: - raise ExecException('Please connect to msfrpcd first') - payload = self.msf.modules.use('payload', command.cmd) + def prepare_payload(self, command: MsfPayloadCommand, msf: MsfRpcClient): + payload = msf.modules.use('payload', command.cmd) payload.runoptions['BadChars'] = command.badchars payload.runoptions['Encoder'] = command.encoder payload.runoptions['Format'] = command.format @@ -79,7 +66,10 @@ def get_local_path(self, command: MsfPayloadCommand): return payload_path async def _exec_cmd(self, command: MsfPayloadCommand) -> Result: - payload = self.prepare_payload(command) + conn_name = self._resolve_connection(command) + msf = self._get_client(conn_name) + + payload = self.prepare_payload(command, msf) try: data = payload.payload_generate() except Exception: diff --git a/src/attackmate/executors/metasploit/msfsessionexecutor.py b/src/attackmate/executors/metasploit/msfsessionexecutor.py index 7c7f28fb1..34af10113 100644 --- a/src/attackmate/executors/metasploit/msfsessionexecutor.py +++ b/src/attackmate/executors/metasploit/msfsessionexecutor.py @@ -1,101 +1,90 @@ import atexit -from pymetasploit3.msfrpc import MsfRpcClient, MsfAuthError +from typing import Dict from attackmate.variablestore import VariableStore from attackmate.executors.metasploit.msfsessionstore import MsfSessionStore from attackmate.executors.baseexecutor import BaseExecutor -from attackmate.execexception import ExecException from attackmate.result import Result from attackmate.schemas.base import BaseCommand from attackmate.schemas.metasploit import MsfSessionCommand +from attackmate.schemas.config import MsfConfig +from attackmate.executors.metasploit.msfclientmixin import MsfClientMixin +from attackmate.execexception import ExecException from attackmate.processmanager import ProcessManager from attackmate.executors.executor_factory import executor_factory +from pymetasploit3.msfrpc import MsfRpcClient @executor_factory.register_executor('msf-session') -class MsfSessionExecutor(BaseExecutor): +class MsfSessionExecutor(MsfClientMixin, BaseExecutor): def __init__( self, pm: ProcessManager, cmdconfig=None, *, varstore: VariableStore, - msfconfig=None, + msf_config: Dict[str, MsfConfig] = {}, msfsessionstore: MsfSessionStore, ): - self.msfconfig = msfconfig + self.msf_config = msf_config + self._msf_clients: Dict[str, MsfRpcClient] = {} self.sessionstore = msfsessionstore - self.msf = None atexit.register(self.cleanup) super().__init__(pm, varstore, cmdconfig) - def connect(self, msfconfig=None): - try: - self.msf = MsfRpcClient(**msfconfig.dict()) - except IOError as e: - self.logger.error(e) - self.msf = None - except MsfAuthError as e: - self.logger.error(e) - self.msf = None - def log_command(self, command: BaseCommand): - if self.msf is None: - self.logger.debug('Connecting to msf-server...') - self.connect(self.msfconfig) self.logger.info(f"Executing Msf-Session-Command: '{command.cmd}'") async def _exec_cmd(self, command: MsfSessionCommand) -> Result: - if self.msf is None: - raise ExecException('ConnectionError') + conn_name = self._resolve_connection(command) + msf = self._get_client(conn_name) - session_id = self.sessionstore.get_session_by_name(command.session, self.msf.sessions) + session_id = self.sessionstore.get_session_by_name(conn_name, command.session, msf.sessions) self.logger.debug(f'Using session-id: {session_id}') return_empty = False try: - if command.stdapi: self.logger.info('Loading stapi') - self.msf.sessions.session(session_id).write('load stdapi') + msf.sessions.session(session_id).write('load stdapi') if command.write: self.logger.info('Writing raw-data in msf-session') - self.msf.sessions.session(session_id).write(command.cmd) + msf.sessions.session(session_id).write(command.cmd) output = '' return_empty = True if command.read: self.logger.info('Reading raw-data in msf-session') - output = self.msf.sessions.session(session_id).read() + output = msf.sessions.session(session_id).read() return Result(output, 0) if not return_empty: self.logger.info('Executing a msf-command') try: - output = self.msf.sessions.session(session_id).run_with_output( + output = msf.sessions.session(session_id).run_with_output( command.cmd, command.end_str ) except TypeError: self.logger.debug('Fallback: First raw-write again and then raw-read data in msf-session') - self.msf.sessions.session(session_id).write(command.cmd) - output = self.msf.sessions.session(session_id).read() + msf.sessions.session(session_id).write(command.cmd) + output = msf.sessions.session(session_id).read() except KeyError as e: - self.logger.debug(self.msf.sessions.list) + self.logger.debug(msf.sessions.list) self.logger.debug(session_id) raise ExecException(e) return Result(output, 0) def cleanup(self): - if self.msf is not None: - self.logger.debug('Killing all Meterpreter sessions') - active_sessions = self.msf.sessions.list + for conn_name, msf in self._msf_clients.items(): + self.logger.debug(f'Killing all Meterpreter sessions for connection: {conn_name}') + active_sessions = msf.sessions.list if active_sessions: for session_id, session_data in active_sessions.items(): try: self.logger.debug(f'Stopping msf session {session_id}') - self.msf.sessions.session(session_id).stop() + msf.sessions.session(session_id).stop() self.logger.info(f'Msf session {session_id} stopped successfully.') except Exception as e: self.logger.error(f'Failed to stop msf session {session_id}: {str(e)}') else: - self.logger.debug('No active msf sessions found.') + self.logger.debug(f'No active msf sessions found for connection: {conn_name}') diff --git a/src/attackmate/executors/metasploit/msfsessionstore.py b/src/attackmate/executors/metasploit/msfsessionstore.py index bf2bdc22f..a250aef74 100644 --- a/src/attackmate/executors/metasploit/msfsessionstore.py +++ b/src/attackmate/executors/metasploit/msfsessionstore.py @@ -8,64 +8,75 @@ class MsfSessionStore: def __init__(self, varstore: VariableStore) -> None: - self.sessions: Dict[str, str] = {} + self.sessions: Dict[str, Dict[str, str]] = {} self.logger = logging.getLogger('playbook') self.varstore = varstore self.queue: Optional[JoinableQueue] = None self.get_session_wait_time = 5 - def add_session(self, name: str, uuid: str) -> None: - self.logger.debug(f'Set MSF-Session: {name} = {uuid}') - self.sessions[name] = uuid + def _conn_sessions(self, conn_name: str) -> Dict[str, str]: + if conn_name not in self.sessions: + self.sessions[conn_name] = {} + return self.sessions[conn_name] - def get_session_by_name(self, name: str, msfsessions, block: bool = True) -> str: + def add_session(self, conn_name: str, name: str, uuid: str) -> None: + self.logger.debug(f'Set MSF-Session [{conn_name}]: {name} = {uuid}') + self._conn_sessions(conn_name)[name] = uuid + + def get_session_by_name(self, conn_name: str, name: str, msfsessions, block: bool = True) -> str: + conn_sessions = self._conn_sessions(conn_name) while True: if self.queue: while not self.queue.empty(): item = self.queue.get() - self.logger.debug(f'Session from Queue: {item[0]} = {item[1]}') - self.add_session(item[0], item[1]) - self.logger.debug(f'Set LAST_MSF_SESSION to {item[2]}') - self.varstore.set_variable('LAST_MSF_SESSION', item[2]) + # item: (conn_name, session_name, uuid, session_id) + self.logger.debug(f'Session from Queue: [{item[0]}] {item[1]} = {item[2]}') + self.add_session(item[0], item[1], item[2]) + self.logger.debug(f'Set LAST_MSF_SESSION to {item[3]}') + self.varstore.set_variable('LAST_MSF_SESSION', item[3]) + self.varstore.set_variable(f'LAST_MSF_SESSION_{item[0]}', item[3]) self.queue.task_done() time.sleep(self.get_session_wait_time) for k, v in msfsessions.list.items(): - if name in self.sessions: - if v['exploit_uuid'] == self.sessions[name]: + if name in conn_sessions: + if v['exploit_uuid'] == conn_sessions[name]: return k else: self.logger.debug( - f'uuid {self.sessions[name]} does not match with any entry in sessions') + f'uuid {conn_sessions[name]} does not match with any entry in sessions') else: - self.logger.debug(f'{name} not found in msfsessions') + self.logger.debug(f'{name} not found in msfsessions for connection {conn_name}') if not block: raise ExecException(f'Session ({name}) not found') else: time.sleep(3) - def add_or_queue_session(self, name: str, uuid: str, queue: Optional[JoinableQueue] = None, + def add_or_queue_session(self, conn_name: str, name: str, uuid: str, + queue: Optional[JoinableQueue] = None, session_id: Optional[int] = None): seconds = 30 if queue: - queue.put((name, uuid, session_id)) + queue.put((conn_name, name, uuid, session_id)) else: - self.add_session(name, uuid) + self.add_session(conn_name, name, uuid) self.logger.debug(f'Set LAST_MSF_SESSION: {session_id}') self.varstore.set_variable('LAST_MSF_SESSION', str(session_id)) + self.varstore.set_variable(f'LAST_MSF_SESSION_{conn_name}', str(session_id)) self.logger.debug(f'Waiting {seconds} seconds for session to get ready') time.sleep(seconds) - def wait_for_session(self, name: str, uuid: str, msfsessions, queue: Optional[JoinableQueue] = None): + def wait_for_session(self, conn_name: str, name: str, uuid: str, msfsessions, + queue: Optional[JoinableQueue] = None): self.logger.debug(f'Sessions: {msfsessions.list}') while True: if len(list(msfsessions.list.keys())) > 0: for session_id, v in msfsessions.list.items(): if v['exploit_uuid'] == uuid: - self.add_or_queue_session(name, uuid, queue, session_id) + self.add_or_queue_session(conn_name, name, uuid, queue, session_id) return - def wait_for_increased_session(self, name: str, uuid: str, msfsessions, + def wait_for_increased_session(self, conn_name: str, name: str, uuid: str, msfsessions, queue: Optional[JoinableQueue] = None): stored_list_len = len(list(msfsessions.list.keys())) sessions = list(msfsessions.list.keys()) @@ -73,6 +84,6 @@ def wait_for_increased_session(self, name: str, uuid: str, msfsessions, if len(list(msfsessions.list.keys())) > stored_list_len: for session_id, v in msfsessions.list.items(): if session_id not in sessions: - self.add_or_queue_session(name, v['exploit_uuid'], queue, session_id) + self.add_or_queue_session(conn_name, name, v['exploit_uuid'], queue, session_id) self.logger.debug(f'Sessions: {msfsessions.list}') return diff --git a/src/attackmate/executors/sliver/sliverclientmixin.py b/src/attackmate/executors/sliver/sliverclientmixin.py new file mode 100644 index 000000000..dc29e8aa0 --- /dev/null +++ b/src/attackmate/executors/sliver/sliverclientmixin.py @@ -0,0 +1,33 @@ +import logging +from typing import Dict +from sliver import SliverClientConfig, SliverClient +from attackmate.schemas.base import BaseCommand +from attackmate.schemas.config import SliverConfig +from attackmate.execexception import ExecException + + +class SliverClientMixin: + sliver_config: Dict[str, SliverConfig] + _sliver_clients: Dict[str, SliverClient] + logger: logging.Logger + + def _resolve_connection(self, command: BaseCommand) -> str: + conn = getattr(command, 'connection', None) + if conn: + return conn + if self.sliver_config: + conn_name = next(iter(self.sliver_config)) + self.logger.debug(f'No connection specified, using default: {conn_name}') + return conn_name + raise ExecException('No Sliver connections configured') + + def _get_client(self, conn_name: str) -> SliverClient: + if conn_name not in self._sliver_clients: + if conn_name not in self.sliver_config: + raise ExecException(f"Sliver connection '{conn_name}' not found in config") + config = self.sliver_config[conn_name] + if not config.config_file: + raise ExecException(f"Sliver connection '{conn_name}' has no config_file") + client_config = SliverClientConfig.parse_config_file(config.config_file) + self._sliver_clients[conn_name] = SliverClient(client_config) + return self._sliver_clients[conn_name] diff --git a/src/attackmate/executors/sliver/sliverexecutor.py b/src/attackmate/executors/sliver/sliverexecutor.py index d4455b322..7cf0d8037 100644 --- a/src/attackmate/executors/sliver/sliverexecutor.py +++ b/src/attackmate/executors/sliver/sliverexecutor.py @@ -6,8 +6,8 @@ import os import tempfile -from typing import Any, Optional -from sliver import SliverClientConfig, SliverClient +from typing import Any, Dict, Optional +from sliver import SliverClient from sliver.protobuf import client_pb2 from attackmate.variablestore import VariableStore from attackmate.executors.baseexecutor import BaseExecutor @@ -15,25 +15,26 @@ from attackmate.result import Result from attackmate.executors.features.cmdvars import CmdVars from attackmate.schemas.base import BaseCommand +from attackmate.schemas.config import SliverConfig from attackmate.schemas.sliver import SliverGenerateCommand, SliverHttpsListenerCommand +from attackmate.executors.sliver.sliverclientmixin import SliverClientMixin from attackmate.processmanager import ProcessManager from attackmate.executors.executor_factory import executor_factory @executor_factory.register_executor('sliver') -class SliverExecutor(BaseExecutor): +class SliverExecutor(SliverClientMixin, BaseExecutor): - def __init__(self, pm: ProcessManager, cmdconfig=None, *, varstore: VariableStore, sliver_config=None): + def __init__( + self, pm: ProcessManager, cmdconfig=None, *, + varstore: VariableStore, sliver_config: Dict[str, SliverConfig] = {} + ): self.sliver_config = sliver_config - self.client = None + self._sliver_clients: Dict[str, SliverClient] = {} + self.client: Optional[SliverClient] = None self.tempfilestore: list[Any] = [] - self.client_config = None self.result = Result('', 1) - - if self.sliver_config.config_file: - self.client_config = SliverClientConfig.parse_config_file(sliver_config.config_file) - self.client = SliverClient(self.client_config) super().__init__(pm, varstore, cmdconfig) async def connect(self) -> None: @@ -139,33 +140,34 @@ async def cleanup(self): """ Cleans up listeners (jobs), sessions, and beacons to release ports and resources. """ - if not self.client: - return - try: - if not self.client.is_connected: - await self.client.connect() - # 1. Kill Jobs (releases ports) - jobs = await self.client.jobs() - for job in jobs: - self.logger.debug(f'Killing sliver job {job}') - await self.client.kill_job(job.ID) - # 2. Kill Sessions - sessions = await self.client.sessions() - for session in sessions: - self.logger.debug(f'Killing sliver session {session.ID}') - await self.client.kill_session(session.ID) - # 3. Kill Beacons - beacons = await self.client.beacons() - for beacon in beacons: - self.logger.debug(f'Killing sliver beacon {beacon.ID}') - await self.client.kill_beacon(beacon.ID) - except Exception as e: - self.logger.error(f'Error during SliverExecutor cleanup: {e}') + for conn_name, client in self._sliver_clients.items(): + try: + if not client.is_connected: + await client.connect() + # 1. Kill Jobs (releases ports) + jobs = await client.jobs() + for job in jobs: + self.logger.debug(f'Killing sliver job {job}') + await client.kill_job(job.ID) + # 2. Kill Sessions + sessions = await client.sessions() + for session in sessions: + self.logger.debug(f'Killing sliver session {session.ID}') + await client.kill_session(session.ID) + # 3. Kill Beacons + beacons = await client.beacons() + for beacon in beacons: + self.logger.debug(f'Killing sliver beacon {beacon.ID}') + await client.kill_beacon(beacon.ID) + except Exception as e: + self.logger.error(f'Error during SliverExecutor cleanup for {conn_name}: {e}') def log_command(self, command: BaseCommand): self.logger.info(f"Executing Sliver-command: '{command.cmd}'") async def _exec_cmd(self, command: BaseCommand) -> Result: + conn_name = self._resolve_connection(command) + self.client = self._get_client(conn_name) await self.connect() try: if command.cmd == 'start_https_listener' and isinstance(command, SliverHttpsListenerCommand): diff --git a/src/attackmate/executors/sliver/sliversessionexecutor.py b/src/attackmate/executors/sliver/sliversessionexecutor.py index df6f33eec..d62e1f643 100644 --- a/src/attackmate/executors/sliver/sliversessionexecutor.py +++ b/src/attackmate/executors/sliver/sliversessionexecutor.py @@ -7,7 +7,8 @@ import asyncio import os import gzip -from sliver import SliverClientConfig, SliverClient +from typing import Dict, Optional +from sliver import SliverClient from sliver.session import InteractiveSession from sliver.beacon import InteractiveBeacon @@ -16,6 +17,8 @@ from attackmate.executors.baseexecutor import BaseExecutor from attackmate.execexception import ExecException from attackmate.result import Result +from attackmate.schemas.config import SliverConfig +from attackmate.executors.sliver.sliverclientmixin import SliverClientMixin from attackmate.schemas.sliver import ( SliverSessionCDCommand, SliverSessionCommand, @@ -38,17 +41,16 @@ @executor_factory.register_executor('sliver-session') -class SliverSessionExecutor(BaseExecutor): +class SliverSessionExecutor(SliverClientMixin, BaseExecutor): - def __init__(self, pm: ProcessManager, cmdconfig=None, *, varstore: VariableStore, sliver_config=None): + def __init__( + self, pm: ProcessManager, cmdconfig=None, *, + varstore: VariableStore, sliver_config: Dict[str, SliverConfig] = {} + ): self.sliver_config = sliver_config - self.client = None - self.client_config = None + self._sliver_clients: Dict[str, SliverClient] = {} + self.client: Optional[SliverClient] = None self.result = Result('', 1) - - if self.sliver_config.config_file: - self.client_config = SliverClientConfig.parse_config_file(sliver_config.config_file) - self.client = SliverClient(self.client_config) super().__init__(pm, varstore, cmdconfig) async def connect(self) -> None: @@ -276,27 +278,28 @@ async def get_session_by_name(self, name) -> InteractiveSession: await asyncio.sleep(seconds) async def cleanup(self): - if not self.client: - return - try: - if not self.client.is_connected: - await self.client.connect() - sessions = await self.client.sessions() - for session in sessions: - self.logger.debug(f'Killing sliver session {session.ID}') - await self.client.kill_session(session.ID) - beacons = await self.client.beacons() - for beacon in beacons: - self.logger.debug(f'Killing sliver beacon {session.ID}') - await self.client.kill_beacon(beacon.ID) - jobs = await self.client.jobs() - for job in jobs: - self.logger.debug(f'Killing sliver job {job}') - await self.client.kill_job(job.ID) - except Exception as e: - self.logger.error(f'Error cleaning up sliver sessions: {e}') + for conn_name, client in self._sliver_clients.items(): + try: + if not client.is_connected: + await client.connect() + sessions = await client.sessions() + for session in sessions: + self.logger.debug(f'Killing sliver session {session.ID}') + await client.kill_session(session.ID) + beacons = await client.beacons() + for beacon in beacons: + self.logger.debug(f'Killing sliver beacon {beacon.ID}') + await client.kill_beacon(beacon.ID) + jobs = await client.jobs() + for job in jobs: + self.logger.debug(f'Killing sliver job {job}') + await client.kill_job(job.ID) + except Exception as e: + self.logger.error(f'Error cleaning up sliver sessions for {conn_name}: {e}') async def _exec_cmd(self, command: SliverSessionCommand) -> Result: + conn_name = self._resolve_connection(command) + self.client = self._get_client(conn_name) await self.connect() try: if command.cmd == 'cd' and isinstance(command, SliverSessionCDCommand): diff --git a/src/attackmate/schemas/config.py b/src/attackmate/schemas/config.py index 5a20de898..33ba7455d 100644 --- a/src/attackmate/schemas/config.py +++ b/src/attackmate/schemas/config.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from typing import Optional, Dict @@ -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): @@ -34,8 +37,38 @@ class RemoteConfig(BaseModel): class Config(BaseModel): - sliver_config: SliverConfig = SliverConfig(config_file=None) - msf_config: MsfConfig = MsfConfig(password=None) + sliver_config: Dict[str, SliverConfig] = {} + msf_config: Dict[str, MsfConfig] = {} cmd_config: CommandConfig = CommandConfig(loop_sleep=5, command_delay=0) bettercap_config: Dict[str, BettercapConfig] = {} remote_config: Dict[str, RemoteConfig] = {} + + @field_validator('msf_config', mode='before') + @classmethod + def migrate_msf_config(cls, v): + # Handle a bare MsfConfig instance passed by embedded-API callers. + if isinstance(v, MsfConfig): + return {'default': v} + # Old configs had a single flat MsfConfig dict (keys: password, ssl, port, server, uri) + # Detect the old format by a known field name AND at least one scalar value + # avoids misdetecting a new-format connection named e.g. 'server'. + # wrapping in "default" + msf_fields = {'password', 'ssl', 'port', 'server', 'uri'} + if (isinstance(v, dict) + and msf_fields.intersection(v.keys()) + and any(not isinstance(val, dict) for val in v.values())): + return {'default': v} + return v + + @field_validator('sliver_config', mode='before') + @classmethod + def migrate_sliver_config(cls, v): + # Handle a bare SliverConfig instance passed by embedded-API callers. + if isinstance(v, SliverConfig): + return {'default': v} + # Same check as above (only one field: config_file) + if (isinstance(v, dict) + and {'config_file'}.intersection(v.keys()) + and any(not isinstance(val, dict) for val in v.values())): + return {'default': v} + return v diff --git a/src/attackmate/schemas/metasploit.py b/src/attackmate/schemas/metasploit.py index e9228af4f..7ce4ee519 100644 --- a/src/attackmate/schemas/metasploit.py +++ b/src/attackmate/schemas/metasploit.py @@ -10,7 +10,9 @@ class MsfSessionCommand(BaseCommand): @field_validator('background') @classmethod def bg_not_implemented_yet(cls, v): - raise ValueError('background mode is unsupported for this command') + if v: + raise ValueError('background mode is unsupported for this command') + return v type: Literal['msf-session'] cmd: str @@ -19,6 +21,7 @@ def bg_not_implemented_yet(cls, v): read: bool = False session: str end_str: Optional[str] = None + connection: Optional[str] = None @CommandRegistry.register('msf-payload') @@ -36,6 +39,7 @@ class MsfPayloadCommand(BaseCommand): iter: StringNumber = '0' payload_options: Dict[str, str] = {} local_path: Optional[str] = None + connection: Optional[str] = None class MsfModuleCommand(BaseCommand): @@ -47,6 +51,7 @@ class MsfModuleCommand(BaseCommand): payload: Optional[str] = None options: Dict[str, str] = {} payload_options: Dict[str, str] = {} + connection: Optional[str] = None def is_interactive(self): if self.interactive is not None: diff --git a/src/attackmate/schemas/sliver.py b/src/attackmate/schemas/sliver.py index 39a403bd4..fe14be9bf 100644 --- a/src/attackmate/schemas/sliver.py +++ b/src/attackmate/schemas/sliver.py @@ -18,6 +18,7 @@ class SliverHttpsListenerCommand(BaseCommand): long_poll_timeout: StringNumber = '1' long_poll_jitter: StringNumber = '3' timeout: StringNumber = '60' + connection: Optional[str] = None @CommandRegistry.register('sliver', 'generate_implant') @@ -35,6 +36,7 @@ class SliverGenerateCommand(BaseCommand): BeaconInterval: StringNumber = '120' RunAtLoad: bool = False Evasion: bool = False + connection: Optional[str] = None @CommandRegistry.register('sliver-session') @@ -42,6 +44,7 @@ class SliverSessionCommand(BaseCommand): type: Literal['sliver-session'] session: str beacon: bool = False + connection: Optional[str] = None @CommandRegistry.register('sliver-session', 'cd') 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) diff --git a/test/units/test_msfclientmixin.py b/test/units/test_msfclientmixin.py new file mode 100644 index 000000000..1fb68ac90 --- /dev/null +++ b/test/units/test_msfclientmixin.py @@ -0,0 +1,191 @@ +import pytest +from unittest.mock import MagicMock, patch +from pymetasploit3.msfrpc import MsfAuthError + +from attackmate.execexception import ExecException +from attackmate.executors.metasploit.msfexecutor import MsfModuleExecutor +from attackmate.executors.metasploit.msfsessionexecutor import MsfSessionExecutor +from attackmate.executors.metasploit.msfpayloadexecutor import MsfPayloadExecutor +from attackmate.executors.metasploit.msfsessionstore import MsfSessionStore +from attackmate.schemas.config import MsfConfig +from attackmate.schemas.metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand +from attackmate.variablestore import VariableStore +from attackmate.processmanager import ProcessManager + + +@pytest.fixture +def varstore(): + return VariableStore() + + +@pytest.fixture +def pm(): + return ProcessManager() + + +@pytest.fixture +def sessionstore(varstore): + return MsfSessionStore(varstore) + + +@pytest.fixture +def single_config(): + return {'default': MsfConfig(password='secret', server='127.0.0.1')} + + +@pytest.fixture +def multi_config(): + return { + 'server1': MsfConfig(password='pw1', server='127.0.0.1'), + 'server2': MsfConfig(password='pw2', server='127.0.0.2'), + } + + +@pytest.fixture +def executor_single(pm, varstore, sessionstore, single_config): + return MsfModuleExecutor(pm, varstore=varstore, msf_config=single_config, msfsessionstore=sessionstore) + + +@pytest.fixture +def executor_multi(pm, varstore, sessionstore, multi_config): + return MsfModuleExecutor(pm, varstore=varstore, msf_config=multi_config, msfsessionstore=sessionstore) + + +# --- _resolve_connection --- + +class TestMsfResolveConnection: + def test_no_connection_field_uses_first_entry(self, executor_single): + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler') + assert executor_single._resolve_connection(cmd) == 'default' + + def test_no_connection_field_uses_first_of_multiple(self, executor_multi): + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler') + assert executor_multi._resolve_connection(cmd) == 'server1' + + def test_explicit_connection_overrides_default(self, executor_multi): + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler', connection='server2') + assert executor_multi._resolve_connection(cmd) == 'server2' + + def test_empty_config_raises(self, pm, varstore, sessionstore): + executor = MsfModuleExecutor(pm, varstore=varstore, msf_config={}, msfsessionstore=sessionstore) + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler') + with pytest.raises(ExecException, match='No MSF connections configured'): + executor._resolve_connection(cmd) + + +# --- _get_client --- + +class TestMsfGetClient: + def test_connects_lazily_on_first_call(self, executor_single): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient') as mock_cls: + mock_cls.return_value = MagicMock() + executor_single._get_client('default') + mock_cls.assert_called_once() + assert 'default' in executor_single._msf_clients + + def test_passes_config_fields_to_rpc_client(self, executor_single): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient') as mock_cls: + mock_cls.return_value = MagicMock() + executor_single._get_client('default') + call_kwargs = mock_cls.call_args[1] + assert call_kwargs['password'] == 'secret' + assert call_kwargs['server'] == '127.0.0.1' + + def test_returns_cached_client_on_subsequent_calls(self, executor_single): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient') as mock_cls: + mock_cls.return_value = MagicMock() + first = executor_single._get_client('default') + second = executor_single._get_client('default') + mock_cls.assert_called_once() + assert first is second + + def test_creates_independent_client_per_connection(self, executor_multi): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient') as mock_cls: + mock_cls.side_effect = [MagicMock(), MagicMock()] + c1 = executor_multi._get_client('server1') + c2 = executor_multi._get_client('server2') + assert c1 is not c2 + assert mock_cls.call_count == 2 + + def test_raises_for_unknown_connection_name(self, executor_multi): + with pytest.raises(ExecException, match="MSF connection 'unknown' not found in config"): + executor_multi._get_client('unknown') + + def test_raises_on_io_error(self, executor_single): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient', + side_effect=IOError('connection refused')): + with pytest.raises(ExecException, match='MSF connection failed'): + executor_single._get_client('default') + + def test_raises_on_auth_error(self, executor_single): + with patch('attackmate.executors.metasploit.msfclientmixin.MsfRpcClient', + side_effect=MsfAuthError('bad password')): + with pytest.raises(ExecException, match='MSF authentication failed'): + executor_single._get_client('default') + + +# --- command connection field --- + +class TestMsfCommandConnectionField: + def test_msf_module_command_defaults_to_none(self): + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler') + assert cmd.connection is None + + def test_msf_module_command_accepts_connection(self): + cmd = MsfModuleCommand(type='msf-module', cmd='exploit/multi/handler', connection='server2') + assert cmd.connection == 'server2' + + def test_msf_session_command_accepts_connection(self): + cmd = MsfSessionCommand(type='msf-session', cmd='sysinfo', session='my_session', connection='server2') + assert cmd.connection == 'server2' + + def test_msf_payload_command_accepts_connection(self): + cmd = MsfPayloadCommand( + type='msf-payload', + cmd='windows/meterpreter/reverse_tcp', + connection='server1') + assert cmd.connection == 'server1' + + +# --- mixin wiring for all three MSF executors --- + +class TestMsfExecutorMixinWiring: + def test_module_executor_has_resolve_and_get_client(self, pm, varstore, sessionstore, multi_config): + executor = MsfModuleExecutor( + pm, + varstore=varstore, + msf_config=multi_config, + msfsessionstore=sessionstore) + assert hasattr(executor, '_resolve_connection') + assert hasattr(executor, '_get_client') + + def test_session_executor_has_resolve_and_get_client(self, pm, varstore, sessionstore, multi_config): + executor = MsfSessionExecutor( + pm, + varstore=varstore, + msf_config=multi_config, + msfsessionstore=sessionstore) + assert hasattr(executor, '_resolve_connection') + assert hasattr(executor, '_get_client') + + def test_payload_executor_has_resolve_and_get_client(self, pm, varstore, multi_config): + executor = MsfPayloadExecutor(pm, varstore=varstore, msf_config=multi_config) + assert hasattr(executor, '_resolve_connection') + assert hasattr(executor, '_get_client') + + def test_session_executor_resolves_connection(self, pm, varstore, sessionstore, multi_config): + executor = MsfSessionExecutor( + pm, + varstore=varstore, + msf_config=multi_config, + msfsessionstore=sessionstore) + cmd = MsfSessionCommand(type='msf-session', cmd='sysinfo', session='s', connection='server2') + assert executor._resolve_connection(cmd) == 'server2' + + def test_payload_executor_resolves_connection(self, pm, varstore, multi_config): + executor = MsfPayloadExecutor(pm, varstore=varstore, msf_config=multi_config) + cmd = MsfPayloadCommand( + type='msf-payload', + cmd='windows/meterpreter/reverse_tcp', + connection='server1') + assert executor._resolve_connection(cmd) == 'server1' diff --git a/test/units/test_multi_connections_config.py b/test/units/test_multi_connections_config.py new file mode 100644 index 000000000..dff5f3f74 --- /dev/null +++ b/test/units/test_multi_connections_config.py @@ -0,0 +1,102 @@ +from attackmate.schemas.config import Config + + +class TestMsfConfigMigration: + def test_old_flat_format_migrated_to_default_key(self): + data = {'msf_config': {'password': 'secret', 'server': '127.0.0.1'}} + c = Config.model_validate(data) + assert 'default' in c.msf_config + assert c.msf_config['default'].server == '127.0.0.1' + assert c.msf_config['default'].password == 'secret' + + def test_old_flat_format_preserves_all_fields(self): + data = { + 'msf_config': { + 'password': 'pw', + 'ssl': False, + 'port': 55554, + 'server': '10.0.0.1', + 'uri': '/rpc/'}} + c = Config.model_validate(data) + cfg = c.msf_config['default'] + assert cfg.port == 55554 + assert cfg.ssl is False + assert cfg.uri == '/rpc/' + + def test_new_named_format_single_entry(self): + data = {'msf_config': {'myserver': {'password': 'secret', 'server': '10.0.0.1'}}} + c = Config.model_validate(data) + assert list(c.msf_config.keys()) == ['myserver'] + assert c.msf_config['myserver'].server == '10.0.0.1' + + def test_new_named_format_multiple_entries(self): + data = { + 'msf_config': { + 'server1': {'password': 'pw1', 'server': '10.0.0.1'}, + 'server2': {'password': 'pw2', 'server': '10.0.0.2'}, + } + } + c = Config.model_validate(data) + assert set(c.msf_config.keys()) == {'server1', 'server2'} + assert c.msf_config['server1'].server == '10.0.0.1' + assert c.msf_config['server2'].server == '10.0.0.2' + + def test_empty_msf_config_stays_empty(self): + c = Config() + assert c.msf_config == {} + + def test_named_entries_preserve_insertion_order(self): + data = { + 'msf_config': { + 'primary': {'server': '10.0.0.1'}, + 'secondary': {'server': '10.0.0.2'}, + } + } + c = Config.model_validate(data) + assert next(iter(c.msf_config)) == 'primary' + + +class TestSliverConfigMigration: + def test_old_flat_format_migrated_to_default_key(self): + data = {'sliver_config': {'config_file': '/home/user/.sliver/config.cfg'}} + c = Config.model_validate(data) + assert 'default' in c.sliver_config + assert c.sliver_config['default'].config_file == '/home/user/.sliver/config.cfg' + + def test_old_flat_format_with_none_config_file(self): + data = {'sliver_config': {'config_file': None}} + c = Config.model_validate(data) + assert 'default' in c.sliver_config + assert c.sliver_config['default'].config_file is None + + def test_new_named_format_single_entry(self): + data = {'sliver_config': {'primary': {'config_file': '/path/to/cfg.cfg'}}} + c = Config.model_validate(data) + assert list(c.sliver_config.keys()) == ['primary'] + assert c.sliver_config['primary'].config_file == '/path/to/cfg.cfg' + + def test_new_named_format_multiple_entries(self): + data = { + 'sliver_config': { + 'sliver1': {'config_file': '/path/cfg1.cfg'}, + 'sliver2': {'config_file': '/path/cfg2.cfg'}, + } + } + c = Config.model_validate(data) + assert set(c.sliver_config.keys()) == {'sliver1', 'sliver2'} + assert c.sliver_config['sliver1'].config_file == '/path/cfg1.cfg' + assert c.sliver_config['sliver2'].config_file == '/path/cfg2.cfg' + + def test_empty_sliver_config_stays_empty(self): + c = Config() + assert c.sliver_config == {} + + def test_named_entries_preserve_insertion_order(self): + data = { + 'sliver_config': { + 'first': {'config_file': '/path/a.cfg'}, + 'second': {'config_file': '/path/b.cfg'}, + } + } + c = Config.model_validate(data) + assert next(iter(c.sliver_config)) == 'first' diff --git a/test/units/test_sliverclientmixin.py b/test/units/test_sliverclientmixin.py new file mode 100644 index 000000000..fcdd1c09c --- /dev/null +++ b/test/units/test_sliverclientmixin.py @@ -0,0 +1,181 @@ +import pytest +from unittest.mock import MagicMock, patch + +from attackmate.execexception import ExecException +from attackmate.executors.sliver.sliverexecutor import SliverExecutor +from attackmate.executors.sliver.sliversessionexecutor import SliverSessionExecutor +from attackmate.schemas.config import SliverConfig +from attackmate.schemas.sliver import ( + SliverHttpsListenerCommand, + SliverGenerateCommand, + SliverSessionSimpleCommand, +) +from attackmate.variablestore import VariableStore +from attackmate.processmanager import ProcessManager + + +@pytest.fixture +def varstore(): + return VariableStore() + + +@pytest.fixture +def pm(): + return ProcessManager() + + +@pytest.fixture +def single_config(): + return {'default': SliverConfig(config_file='/path/to/config.cfg')} + + +@pytest.fixture +def multi_config(): + return { + 'sliver1': SliverConfig(config_file='/path/to/cfg1.cfg'), + 'sliver2': SliverConfig(config_file='/path/to/cfg2.cfg'), + } + + +@pytest.fixture +def executor_single(pm, varstore, single_config): + return SliverExecutor(pm, varstore=varstore, sliver_config=single_config) + + +@pytest.fixture +def executor_multi(pm, varstore, multi_config): + return SliverExecutor(pm, varstore=varstore, sliver_config=multi_config) + + +# --- _resolve_connection --- + +class TestSliverResolveConnection: + def test_no_connection_field_uses_first_entry(self, executor_single): + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener') + assert executor_single._resolve_connection(cmd) == 'default' + + def test_no_connection_field_uses_first_of_multiple(self, executor_multi): + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener') + assert executor_multi._resolve_connection(cmd) == 'sliver1' + + def test_explicit_connection_overrides_default(self, executor_multi): + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener', connection='sliver2') + assert executor_multi._resolve_connection(cmd) == 'sliver2' + + def test_empty_config_raises(self, pm, varstore): + executor = SliverExecutor(pm, varstore=varstore, sliver_config={}) + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener') + with pytest.raises(ExecException, match='No Sliver connections configured'): + executor._resolve_connection(cmd) + + def test_session_command_connection_resolved(self, executor_multi): + cmd = SliverSessionSimpleCommand( + type='sliver-session', + cmd='pwd', + session='test', + connection='sliver2') + assert executor_multi._resolve_connection(cmd) == 'sliver2' + + +# --- _get_client --- + +class TestSliverGetClient: + def test_parses_config_file_on_first_call(self, executor_single): + with patch('attackmate.executors.sliver.sliverclientmixin.SliverClientConfig') as mock_cfg, \ + patch('attackmate.executors.sliver.sliverclientmixin.SliverClient'): + mock_cfg.parse_config_file.return_value = MagicMock() + executor_single._get_client('default') + mock_cfg.parse_config_file.assert_called_once_with('/path/to/config.cfg') + + def test_creates_sliver_client_on_first_call(self, executor_single): + with patch('attackmate.executors.sliver.sliverclientmixin.SliverClientConfig') as mock_cfg, \ + patch('attackmate.executors.sliver.sliverclientmixin.SliverClient') as mock_client_cls: + mock_cfg.parse_config_file.return_value = MagicMock() + mock_client_cls.return_value = MagicMock() + executor_single._get_client('default') + mock_client_cls.assert_called_once() + assert 'default' in executor_single._sliver_clients + + def test_returns_cached_client_on_subsequent_calls(self, executor_single): + with patch('attackmate.executors.sliver.sliverclientmixin.SliverClientConfig') as mock_cfg, \ + patch('attackmate.executors.sliver.sliverclientmixin.SliverClient') as mock_client_cls: + mock_cfg.parse_config_file.return_value = MagicMock() + mock_client_cls.return_value = MagicMock() + first = executor_single._get_client('default') + second = executor_single._get_client('default') + mock_cfg.parse_config_file.assert_called_once() + assert first is second + + def test_creates_independent_client_per_connection(self, executor_multi): + with patch('attackmate.executors.sliver.sliverclientmixin.SliverClientConfig') as mock_cfg, \ + patch('attackmate.executors.sliver.sliverclientmixin.SliverClient') as mock_client_cls: + mock_cfg.parse_config_file.return_value = MagicMock() + mock_client_cls.side_effect = [MagicMock(), MagicMock()] + c1 = executor_multi._get_client('sliver1') + c2 = executor_multi._get_client('sliver2') + assert c1 is not c2 + assert mock_client_cls.call_count == 2 + + def test_raises_for_unknown_connection_name(self, executor_multi): + with pytest.raises(ExecException, match="Sliver connection 'unknown' not found in config"): + executor_multi._get_client('unknown') + + def test_raises_when_config_file_is_none(self, pm, varstore): + executor = SliverExecutor(pm, varstore=varstore, + sliver_config={'no_file': SliverConfig(config_file=None)}) + with pytest.raises(ExecException, match='has no config_file'): + executor._get_client('no_file') + + +# --- command connection field --- + +class TestSliverCommandConnectionField: + def test_https_listener_defaults_to_none(self): + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener') + assert cmd.connection is None + + def test_https_listener_accepts_connection(self): + cmd = SliverHttpsListenerCommand(type='sliver', cmd='start_https_listener', connection='sliver2') + assert cmd.connection == 'sliver2' + + def test_generate_command_accepts_connection(self): + cmd = SliverGenerateCommand( + type='sliver', cmd='generate_implant', c2url='https://10.0.0.1', name='implant', + connection='sliver1' + ) + assert cmd.connection == 'sliver1' + + def test_session_command_accepts_connection(self): + cmd = SliverSessionSimpleCommand(type='sliver-session', cmd='pwd', session='my_session', + connection='sliver1') + assert cmd.connection == 'sliver1' + + def test_session_command_connection_inherited_by_subclasses(self): + cmd = SliverSessionSimpleCommand(type='sliver-session', cmd='ps', session='my_session', + connection='sliver2') + assert cmd.connection == 'sliver2' + + +# --- mixin wiring for both Sliver executors --- + +class TestSliverExecutorMixinWiring: + def test_sliver_executor_has_resolve_and_get_client(self, pm, varstore, multi_config): + executor = SliverExecutor(pm, varstore=varstore, sliver_config=multi_config) + assert hasattr(executor, '_resolve_connection') + assert hasattr(executor, '_get_client') + + def test_sliver_session_executor_has_resolve_and_get_client(self, pm, varstore, multi_config): + executor = SliverSessionExecutor(pm, varstore=varstore, sliver_config=multi_config) + assert hasattr(executor, '_resolve_connection') + assert hasattr(executor, '_get_client') + + def test_session_executor_resolves_connection(self, pm, varstore, multi_config): + executor = SliverSessionExecutor(pm, varstore=varstore, sliver_config=multi_config) + cmd = SliverSessionSimpleCommand(type='sliver-session', cmd='pwd', session='s', connection='sliver2') + assert executor._resolve_connection(cmd) == 'sliver2' + + def test_session_executor_raises_with_no_config(self, pm, varstore): + executor = SliverSessionExecutor(pm, varstore=varstore, sliver_config={}) + cmd = SliverSessionSimpleCommand(type='sliver-session', cmd='pwd', session='s') + with pytest.raises(ExecException, match='No Sliver connections configured'): + executor._resolve_connection(cmd) diff --git a/test/units/test_sliverexecutor.py b/test/units/test_sliverexecutor.py index dd4d44ca7..0494fca19 100644 --- a/test/units/test_sliverexecutor.py +++ b/test/units/test_sliverexecutor.py @@ -8,12 +8,9 @@ @pytest.fixture def setup_executor(): - # Mock dependencies and create a SliverExecutor instance pm = MagicMock() # Mock ProcessManager varstore = MagicMock() # Mock VariableStore - sliver_config = MagicMock() # Mock SliverConfig - sliver_config.config_file = None - executor = SliverExecutor(pm, varstore=varstore, sliver_config=sliver_config) + executor = SliverExecutor(pm, varstore=varstore, sliver_config={}) return executor diff --git a/uv.lock b/uv.lock index ecc23421b..761ec1b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -121,7 +121,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "argon2-cffi" }, - { name = "attackmate-client", git = "https://github.com/ait-testbed/attackmate-client.git" }, + { name = "attackmate-client", specifier = ">=0.1.2" }, { name = "colorlog" }, { name = "dotenv" }, { name = "fastapi" }, @@ -151,12 +151,16 @@ dev = [ [[package]] name = "attackmate-client" version = "0.1.2" -source = { git = "https://github.com/ait-testbed/attackmate-client.git#4dd9940370048f593b1d4f2145a66859c505d88c" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "pyyaml" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/fd/26/6a97b8ab9ef7ee32ed5e93255b8dbc3887de2e9f675c331ae5d554ee5dc7/attackmate_client-0.1.2.tar.gz", hash = "sha256:a259d64f9041e89e6734ea65ac0a1b4568b256dfc08e95fa70adbb83211644f3", size = 16106, upload-time = "2026-05-21T11:27:16.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/89/c9ae9e9c7484011d94491c7fad6d92637a35ffa381aa7ac35e2ba60a1e14/attackmate_client-0.1.2-py3-none-any.whl", hash = "sha256:6606d45b0510aeaa8058733b6f84ef19bd8d9346f63be498b12754fe033b834c", size = 14692, upload-time = "2026-05-21T11:27:15.135Z" }, +] [[package]] name = "attrs"