From eedb8d7eabb8af38877f393536c5bcb93e6fff7e Mon Sep 17 00:00:00 2001 From: kali Date: Fri, 2 May 2025 22:11:08 +0200 Subject: [PATCH 01/49] poc fastapi --- remote_rest/README.md | 0 remote_rest/__int__.py | 0 remote_rest/client.py | 322 ++++++++++++++++++++++++++++++++ remote_rest/main.py | 0 remote_rest/server.py | 413 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 735 insertions(+) create mode 100644 remote_rest/README.md create mode 100644 remote_rest/__int__.py create mode 100644 remote_rest/client.py create mode 100644 remote_rest/main.py create mode 100644 remote_rest/server.py diff --git a/remote_rest/README.md b/remote_rest/README.md new file mode 100644 index 00000000..e69de29b diff --git a/remote_rest/__int__.py b/remote_rest/__int__.py new file mode 100644 index 00000000..e69de29b diff --git a/remote_rest/client.py b/remote_rest/client.py new file mode 100644 index 00000000..8c1715f1 --- /dev/null +++ b/remote_rest/client.py @@ -0,0 +1,322 @@ +import httpx +import argparse +import yaml +import logging +import sys +import json +from typing import Dict, Any, List, Optional + + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - CLIENT - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +# Helper Functions +def parse_key_value_pairs(items: List[str] | None) -> Dict[str, str]: + """Helper to parse 'key=value' strings from a list into a dict.""" + result: Dict[str, str] = {} + if not items: + return result + for item in items: + if '=' in item: + key, value = item.split('=', 1) + result[key.strip()] = value.strip() + else: + logging.warning(f"Skipping malformed pair: {item}") + return result + + +def create_instance(client: httpx.Client, base_url: str) -> Optional[str]: + """Requests creation of a new persistent instance.""" + url = f"{base_url}/instances" + logger.info(f"Requesting new instance creation at {url}...") + try: + response = client.post(url) + response.raise_for_status() + data = response.json() + instance_id = data.get('instance_id') + logger.info(f"Successfully created instance: {instance_id}") + print(f"Instance Created: {instance_id}") + return instance_id + except httpx.RequestError as e: + logger.error(f"HTTP Request Error creating instance: {e}") + return None + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error creating instance: {e.response.status_code} - {e.response.text}") + return None + except Exception as e: + logger.error(f"Unexpected error creating instance: {e}", exc_info=True) + return None + + +def delete_instance_on_server(client: httpx.Client, base_url: str, instance_id: str): + """Requests deletion of a persistent instance.""" + url = f"{base_url}/instances/{instance_id}" + logger.info(f"Requesting deletion of instance {instance_id} at {url}...") + try: + response = client.delete(url) + response.raise_for_status() + logger.info(f"Successfully requested deletion of instance: {instance_id} (Status: {response.status_code})") + print(f"Instance {instance_id} deletion requested.") + except httpx.RequestError as e: + logger.error(f"HTTP Request Error deleting instance: {e}") + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + logger.warning(f"Instance {instance_id} not found on server for deletion.") + else: + logger.error(f"HTTP Status Error deleting instance: {e.response.status_code} - {e.response.text}") + except Exception as e: + logger.error(f"Unexpected error deleting instance: {e}", exc_info=True) + + +def get_instance_state_from_server(client: httpx.Client, base_url: str, instance_id: str): + """Requests the state of a specific instance.""" + url = f"{base_url}/instances/{instance_id}/state" + logger.info(f"Requesting state for instance {instance_id} at {url}...") + try: + response = client.get(url) + response.raise_for_status() + data = response.json() + print(f"\n State for Instance {instance_id} ") + print(yaml.dump(data.get('variables', {}), indent=2)) + except httpx.RequestError as e: + logger.error(f"HTTP Request Error getting state: {e}") + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error getting state: {e.response.status_code} - {e.response.text}") + except Exception as e: + logger.error(f"Unexpected error getting state: {e}", exc_info=True) + + +def run_playbook_yaml(client: httpx.Client, base_url: str, playbook_file: str): + """Sends playbook YAML content to the server.""" + url = f"{base_url}/playbooks/execute/yaml" + logger.info(f"Attempting to execute playbook from local file: {playbook_file}") + try: + with open(playbook_file, 'r') as f: + playbook_yaml_content = f.read() + except Exception as e: + logger.error(f"Error reading file '{playbook_file}': {e}") + sys.exit(1) + + try: + response = client.post(url, content=playbook_yaml_content, headers={'Content-Type': 'application/yaml'}) + response.raise_for_status() + data = response.json() + print('\n Playbook YAML Execution Result ') + print(f"Success: {data.get('success')}") + print(f"Message: {data.get('message')}") + if data.get('final_state'): + print('\n Final Variable Store State ') + print(yaml.dump(data['final_state'].get('variables', {}), indent=2)) + if not data.get('success'): + sys.exit(1) + except httpx.RequestError as e: + logger.error(f"HTTP Request Error executing playbook YAML: {e}") + sys.exit(1) + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error (YAML): {e.response.status_code} - {e.response.text}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error (YAML): {e}", exc_info=True) + sys.exit(1) + + +def run_playbook_file(client: httpx.Client, base_url: str, playbook_file_path_on_server: str): + """Requests server to execute a playbook from local path.""" + url = f"{base_url}/playbooks/execute/file" + logger.info(f"Requesting server execute playbook file: {playbook_file_path_on_server}") + payload = {'file_path': playbook_file_path_on_server} + try: + response = client.post(url, json=payload) + response.raise_for_status() + data = response.json() + print('\n Playbook File Execution Result ') + print(f"Success: {data.get('success')}") + print(f"Message: {data.get('message')}") + if data.get('final_state'): + print('\n Final Variable Store State ') + print(yaml.dump(data['final_state'].get('variables', {}), indent=2)) + if not data.get('success'): + sys.exit(1) + except httpx.RequestError as e: + logger.error(f"HTTP Request Error executing playbook file: {e}") + sys.exit(1) + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error (File): {e.response.status_code} - {e.response.text}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error (File): {e}", exc_info=True) + sys.exit(1) + + +def run_command(client: httpx.Client, base_url: str, args): + """Sends a single command to the server for execution against an instance.""" + # TODO this need more special handling for sliver commands? + type = args.type + instance_id = args.instance_id # Get instance ID from args + if not instance_id: + logger.error('Instance ID is required for executing commands.') + sys.exit(1) + + url = f"{base_url}/instances/{instance_id}/{type.replace('_', '-')}" + logger.info(f"Attempting command '{type}' on instance '{instance_id}' at {url}") + + # Construct the request body dictionary (matching Pydantic model) + # Exclude argparse internals + body_dict: Dict[str, Any] = {} + excluded_args = {'mode', 'func', 'server_url', 'instance_id'} + for arg_name, arg_value in vars(args).items(): + if arg_name not in excluded_args and arg_value is not None: + pydantic_field_name = arg_name + # Type conversions for body (Pydantic/FastAPI handles validation, but ensure basic types) + if arg_name in ['option', 'payload_option', 'metadata', 'prompts', 'output_map', 'header', 'cookie', 'data']: + if isinstance(arg_value, list): + body_dict[pydantic_field_name] = parse_key_value_pairs(arg_value) + else: + body_dict[pydantic_field_name] = arg_value + + try: + logger.debug(f"Sending POST to {url}") + logger.debug(f"Request Body: {json.dumps(body_dict, indent=2)}") + response = client.post(url, json=body_dict) + response.raise_for_status() + data = response.json() + logger.info(f"Received response from /{type} endpoint.") + logger.debug(f"Response data: {data}") + + result = data.get('result', {}) + state = data.get('state', {}).get('variables', {}) + + print(f"\n--- Command Result (Instance: {data.get('instance_id', instance_id)}) ---") + print(f"Success: {result.get('success')}") + print(f"Return Code: {result.get('returncode')}") + print(f"Stdout:\n{result.get('stdout')}") + if result.get('error_message'): + print(f"Error Message: {result.get('error_message')}") + + print('\n--- Updated Variable Store State --- ') + print(yaml.dump(state, indent=2, default_flow_style=False)) + + is_background = hasattr(args, 'background') and args.background is True + if not result.get('success') or (result.get('returncode') != 0 and not is_background): + sys.exit(1) + + except httpx.RequestError as e: + logger.error(f"HTTP Request Error executing command: {e}") + sys.exit(1) + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Status Error ({url}): {e.response.status_code} - {e.response.text}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error executing command: {e}", exc_info=True) + sys.exit(1) + + +# Main Execution Logic +def main(): + parser = argparse.ArgumentParser(description='AttackMate REST API Client') + parser.add_argument('--base-url', default='http://localhost:8000', help='Base URL of the AttackMate API server') + subparsers = parser.add_subparsers(dest='mode', required=True, help='Operation mode') + + # Playbook Modes + parser_pb_yaml = subparsers.add_parser('playbook-yaml', help='Execute a playbook from a local YAML file content') + parser_pb_yaml.add_argument('playbook_file', help='Path to the local playbook YAML file') + + parser_pb_file = subparsers.add_parser('playbook-file', help='Request server execute a playbook from its filesystem') + parser_pb_file.add_argument('server_playbook_path', help='Path to the playbook file relative to the server\'s allowed directory') + + # Instance Management Modes + parser_inst_create = subparsers.add_parser('instance-create', help='Create a new persistent AttackMate instance') + parser_inst_delete = subparsers.add_parser('instance-delete', help='Delete a persistent AttackMate instance') + parser_inst_delete.add_argument('instance_id', help='ID of the instance to delete') + parser_inst_state = subparsers.add_parser('instance-state', help='Get the state of an instance') + parser_inst_state.add_argument('instance_id', help='ID of the instance') + + # Command Mode + parser_command = subparsers.add_parser('command', help='Execute a single command on a specific instance') + parser_command.add_argument('instance_id', help='ID of the target persistent instance') + command_subparsers = parser_command.add_subparsers(dest='type', required=True, help='Specific command type') + + # Define Common Arguments Parser (used as parent for command types) + common_args_parser = argparse.ArgumentParser(add_help=False) + common_args_parser.add_argument('--only-if', help='Conditional execution string') + common_args_parser.add_argument('--error-if', help='Regex pattern for error on match') + common_args_parser.add_argument('--error-if-not', help='Regex pattern for error if no match') + common_args_parser.add_argument('--loop-if', help='Regex pattern to loop on match') + common_args_parser.add_argument('--loop-if-not', help='Regex pattern to loop if no match') + common_args_parser.add_argument('--loop-count', type=int, help='Maximum loop iterations') + common_args_parser.add_argument('--exit-on-error', action=argparse.BooleanOptionalAction, help='Exit if command return code is non-zero') + common_args_parser.add_argument('--save', help='File path to save command stdout') + common_args_parser.add_argument('--background', action=argparse.BooleanOptionalAction, help='Run command in the background') + common_args_parser.add_argument('--kill-on-exit', action=argparse.BooleanOptionalAction, help='Kill process on server exit') + common_args_parser.add_argument('--metadata', action='append', help='Metadata key=value pair (repeatable)') + + # Add Command Subparsers + # Shell + parser_shell = command_subparsers.add_parser('shell', help='Execute shell command', parents=[common_args_parser]) + parser_shell.add_argument('cmd', help='The command to execute') + parser_shell.add_argument('--interactive', action=argparse.BooleanOptionalAction) + parser_shell.add_argument('--creates-session', help='Name of shell session to create') + parser_shell.add_argument('--session', help='Name of existing shell session to use') + parser_shell.add_argument('--command-timeout', type=int) + parser_shell.add_argument('--read', action=argparse.BooleanOptionalAction, default=True) + parser_shell.add_argument('--command-shell', help='Shell path') + parser_shell.add_argument('--bin', action=argparse.BooleanOptionalAction) + + # Sleep + parser_sleep = command_subparsers.add_parser('sleep', help='Pause execution', parents=[common_args_parser]) + parser_sleep.add_argument('--seconds', type=int) + parser_sleep.add_argument('--min-sec', type=int) + parser_sleep.add_argument('--random', action=argparse.BooleanOptionalAction) + + # Debug + parser_debug = command_subparsers.add_parser('debug', help='Debug output or pause', parents=[common_args_parser]) + parser_debug.add_argument('--varstore', action=argparse.BooleanOptionalAction) + parser_debug.add_argument('--exit', action=argparse.BooleanOptionalAction) + parser_debug.add_argument('--wait-for-key', action=argparse.BooleanOptionalAction) + + # SetVar + parser_setvar = command_subparsers.add_parser('setvar', help='Set a variable', parents=[common_args_parser]) + parser_setvar.add_argument('variable', help='Name of the variable to set') + parser_setvar.add_argument('cmd', help='Value to assign to the variable') + parser_setvar.add_argument('--encoder', help='Encoder to use') + + # Mktemp (Tempfile) + parser_mktemp = command_subparsers.add_parser('mktemp', help='Create temporary file/directory', parents=[common_args_parser]) + parser_mktemp.add_argument('variable', help='Variable name to store the path') + parser_mktemp.add_argument('--cmd', choices=['file', 'dir'], default='file', help='create a file or directory') + + # ADD SUBPARSERS FOR ALL OTHER COMMAND TYPES HERE + + args = parser.parse_args() + + # Create HTTP Client + with httpx.Client(base_url=args.base_url, timeout=60.0) as client: + try: + # Execute based on mode + if args.mode == 'playbook-yaml': + run_playbook_yaml(client, args.base_url, args.playbook_file) + elif args.mode == 'playbook-file': + run_playbook_file(client, args.base_url, args.server_playbook_path) + elif args.mode == 'instance-create': + create_instance(client, args.base_url) + elif args.mode == 'instance-delete': + delete_instance_on_server(client, args.base_url, args.instance_id) + elif args.mode == 'instance-state': + get_instance_state_from_server(client, args.base_url, args.instance_id) + elif args.mode == 'command': + if hasattr(args, 'type') and args.type: + run_command(client, args.base_url, args) + else: + logger.error('Internal error: Command mode selected but no command type specified.') + parser.print_help() + sys.exit(1) + except Exception as main_err: + logger.error(f"Client execution failed: {main_err}", exc_info=True) + sys.exit(1) + + logger.info('Client finished.') + + +if __name__ == '__main__': + main() diff --git a/remote_rest/main.py b/remote_rest/main.py new file mode 100644 index 00000000..e69de29b diff --git a/remote_rest/server.py b/remote_rest/server.py new file mode 100644 index 00000000..7e65b7e1 --- /dev/null +++ b/remote_rest/server.py @@ -0,0 +1,413 @@ +import uvicorn +import logging +import uuid +import yaml +import os +from fastapi import FastAPI, HTTPException, Body, Path, Request +from fastapi.responses import JSONResponse +from pydantic import Field, ValidationError, BaseModel +from typing import Dict, Any, Optional + +from attackmate.schemas.base import BaseCommand +from src.attackmate.attackmate import AttackMate +from src.attackmate.playbook_parser import parse_config, parse_playbook +from src.attackmate.logging_setup import initialize_logger, initialize_output_logger, initialize_json_logger +from src.attackmate.schemas.config import Config +from src.attackmate.schemas.playbook import Playbook +from src.attackmate.variablestore import VariableStore +from src.attackmate.result import Result as AttackMateResult +from src.attackmate.execexception import ExecException + + +from src.attackmate.schemas.shell import ShellCommand +from src.attackmate.schemas.sleep import SleepCommand +from src.attackmate.schemas.debug import DebugCommand +from src.attackmate.schemas.setvar import SetVarCommand +from src.attackmate.schemas.tempfile import TempfileCommand +# ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE + + +class VariableStoreStateModel(BaseModel): + variables: Dict[str, Any] = {} + + +class CommandResultModel(BaseModel): + success: bool + stdout: Optional[str] = None + returncode: Optional[int] = None + error_message: Optional[str] = None + + +class ExecutionResponseModel(BaseModel): + result: CommandResultModel + state: VariableStoreStateModel + instance_id: Optional[str] = None + + +class PlaybookResponseModel(BaseModel): + success: bool + message: str + final_state: Optional[VariableStoreStateModel] = None + + +class InstanceCreationResponse(BaseModel): + instance_id: str + message: str + + +class PlaybookFileRequest(BaseModel): + file_path: str = Field(..., description='Path to the playbook file RELATIVE to a predefined server directory.') + + +# Logging +initialize_logger(debug=True, append_logs=False) +initialize_output_logger(debug=True, append_logs=False) +initialize_json_logger(json=True, append_logs=False) +logger = logging.getLogger('attackmate_api') # specific logger for the API +# TODO make this configurable via request, even also per attackmate instance? +logger.setLevel(logging.DEBUG) + + +# This holds persistent AttackMate instances +INSTANCES: Dict[str, AttackMate] = {} + +# load config +# TODO pass/modify configs in the request +try: + attackmate_config: Config = parse_config(config_file=None, logger=logger) + logger.info('Global AttackMate configuration loaded.') +except Exception as e: + logger.error(f"Failed to load AttackMate config on startup: {e}", exc_info=True) + exit(1) + + +# HElpers + +def get_attackmate_instance(instance_id: str) -> AttackMate: + """Retrieves an instance""" + instance = INSTANCES.get(instance_id) + if not instance: + logger.warning(f"AttackMate instance '{instance_id}' not found.") + raise HTTPException(status_code=404, detail=f"AttackMate instance '{instance_id}' not found.") + return instance + + +def create_persistent_instance() -> str: + """Creates a new persistent AttackMate instance and returns its ID.""" + instance_id = str(uuid.uuid4()) + logger.info(f"Creating new persistent AttackMate instance with ID: {instance_id}") + try: + # Create with empty playbook/vars + instance = AttackMate(playbook=None, config=attackmate_config, varstore=None) + INSTANCES[instance_id] = instance + logger.info(f"Instance {instance_id} created successfully.") + return instance_id + except Exception as e: + logger.error(f"Failed to create persistent instance {instance_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail='Failed to create AttackMate instance.') + + +def varstore_to_state_model(varstore: VariableStore) -> VariableStoreStateModel: + """Converts AttackMate VariableStore to Pydantic VariableStoreStateModel.""" + combined_vars: Dict[str, Any] = {} + combined_vars.update(varstore.variables) + combined_vars.update(varstore.lists) + return VariableStoreStateModel(variables=combined_vars) + + +async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: + """Runs a command on a given AttackMate instance.""" + try: + logger.info(f"Executing command type '{command_data.type}' on instance") # type: ignore + # TODO does this work? need to pass command class object here? + result = instance.run_command(command_data) + logger.info(f"Command execution finished. RC: {result.returncode}") + return result + except (ExecException, SystemExit) as e: + logger.error(f"AttackMate execution error: {e}", exc_info=True) + raise e + except Exception as e: + logger.error(f"Unexpected error during instance.run_command: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error during command execution: {e}") + + +app = FastAPI( + title='AttackMate REST API', + description='API for remote control of AttackMate instances and playbook execution.', + version='1.0.0' +) + + +# Error handling +# --- Error Handling --- +@app.exception_handler(ExecException) +async def attackmate_execution_exception_handler(request: Request, exc: ExecException): + logger.error(f"AttackMate Execution Exception: {exc}") + return JSONResponse( + status_code=400, # bad request? + content={ + 'detail': 'AttackMate command execution failed', + 'error_message': str(exc), + 'instance_id': None + }, + ) + + +@app.exception_handler(Exception) +async def generic_exception_handler(request: Request, exc: Exception): + if isinstance(exc, SystemExit): + logger.error(f"Command triggered SystemExit with code {exc.code}") + return JSONResponse( + status_code=400, # client-side error pattern + content={ + 'detail': 'Command execution led to termination request', + 'error_message': f"SystemExit triggered (likely due to error condition like 'exit_on_error' or 'error_if'). Exit code: {exc.code}", + 'instance_id': None + }, + ) + # Re-raise other exceptions for specific hanfling? + raise exc + + +# Endpoints + +# Playbook Execution +@app.post('/playbooks/execute/yaml', response_model=PlaybookResponseModel, tags=['Playbooks']) +async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml')): + """ + Executes a playbook provided as YAML content in the request body. + Use a transient AttackMate instance. + """ + logger.info('Received request to execute playbook from YAML content.') + am_instance = None + try: + playbook_dict = yaml.safe_load(playbook_yaml) + if not playbook_dict: + raise ValueError('Received empty or invalid playbook YAML content.') + playbook = Playbook.model_validate(playbook_dict) + + logger.info('Creating transient AttackMate instance...') + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. return code {return_code}") + return PlaybookResponseModel( + success=(return_code == 0), + message='Playbook execution finished.', + final_state=final_state + ) + except (yaml.YAMLError, ValidationError, ValueError) as e: + logger.error(f"Playbook validation/parsing error: {e}") + raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') + try: + am_instance.clean_session_stores() + am_instance.pm.kill_or_wait_processes() + except Exception as cleanup_e: + logger.error(f"Error cleaning transient instance: {cleanup_e}", exc_info=True) + + +@app.post('/playbooks/execute/file', response_model=PlaybookResponseModel, tags=['Playbooks']) +async def execute_playbook_from_file(request_body: PlaybookFileRequest): + """ + Executes a playbook located at a specific path *on the server*. + Uses a transient AttackMate instance. + """ + # TODO ensure this only executes playbooks in certain locations, not arbitrary code -> read up on path traversal + logger.info(f"Received request to execute playbook from file: {request_body.file_path}") + + # Define a secure base directory where playbooks are allowed + ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST and be configured securely + try: + # base directory exists + if not os.path.isdir(ALLOWED_PLAYBOOK_DIR): + logger.error(f"Configuration error: ALLOWED_PLAYBOOK_DIR '{ALLOWED_PLAYBOOK_DIR}' does not exist.") + raise HTTPException(status_code=500, detail='Server configuration error: Playbook directory not found.') + + requested_path = os.path.normpath(request_body.file_path) + # Disallow absolute paths or paths trying to go up directories + if os.path.isabs(requested_path) or requested_path.startswith('..'): + raise ValueError('Invalid playbook path specified.') + + full_path = os.path.join(ALLOWED_PLAYBOOK_DIR, requested_path) + # Final check: ensure the resolved path is still within the allowed directory + if not os.path.abspath(full_path).startswith(os.path.abspath(ALLOWED_PLAYBOOK_DIR)): + raise ValueError('Invalid playbook path specified (path traversal attempt ).') + + # Check if the file exists + if not os.path.isfile(full_path): + raise FileNotFoundError(f"Playbook file not found atpath: {full_path}") + + except (ValueError, FileNotFoundError) as e: + logger.error(f"Invalid or non-existent playbook path requested: {request_body.file_path} -> {e}") + raise HTTPException(status_code=400, detail=f"Invalid or non-existent playbook file path: {e}") + except Exception as e: + logger.error(f"Error processing playbook path: {e}", exc_info=True) + raise HTTPException(status_code=500, detail='Server error processing file path.') + + am_instance = None + try: + # Parse the playbook file + logger.info(f"Parsing playbook from: {full_path}") + playbook = parse_playbook(full_path, logger) + + logger.info('Creating transient AttackMate instance') + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. RC: {return_code}") + return PlaybookResponseModel( + success=(return_code == 0), + message=f"Playbook '{request_body.file_path}' execution finished.", + final_state=final_state + ) + except (ValidationError, ValueError) as e: + logger.error(f"Playbook validation error from file '{full_path}': {e}") + raise HTTPException(status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') + try: + am_instance.clean_session_stores() + am_instance.pm.kill_or_wait_processes() + except Exception as e: + logger.error(f"Cleanup error: {e}") + + +# Attackmate Instances + +@app.post('/instances', response_model=InstanceCreationResponse, tags=['Instances']) +async def create_new_instance(): + """Creates a new persistent AttackMate instance and returns its ID.""" + instance_id = create_persistent_instance() + return InstanceCreationResponse(instance_id=instance_id, message='AttackMate instance created successfully.') + + +@app.delete('/instances/{instance_id}', status_code=204, tags=['Instances']) +async def delete_instance(instance_id: str = Path(..., description='ID of the instance to delete.')): + """Deletes a persistent AttackMate instance.""" + instance = INSTANCES.pop(instance_id, None) + if not instance: + raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") + logger.info(f"Deleting instance {instance_id}...") + try: + instance.clean_session_stores() + instance.pm.kill_or_wait_processes() + logger.info(f"Instance {instance_id} cleaned up and deleted.") + except Exception as e: + logger.error(f"Error during cleanup while deleting instance {instance_id}: {e}", exc_info=True) + return + + +@app.get('/instances/{instance_id}/state', response_model=VariableStoreStateModel, tags=['Instances']) +async def get_instance_state(instance_id: str = Path(..., description='ID of the instance.')): + """Gets the current variable store state for an instance.""" + instance = get_attackmate_instance(instance_id) + return varstore_to_state_model(instance.varstore) + + +# Command Endpoints +@app.post('/instances/{instance_id}/shell', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_shell_command( + command: ShellCommand, + instance_id: str = Path(..., description='ID of the target instance.'), +): + """Executes a shell command on the specified AttackMate instance.""" + logger.info(f"Received shell command request for instance {instance_id}.") + instance = get_attackmate_instance(instance_id) # Raise 404 if not found + attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands + + # response + result_model = CommandResultModel( + success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), # Success if RC 0 or None (background) + stdout=attackmate_result.stdout, + returncode=attackmate_result.returncode + ) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@app.post('/instances/{instance_id}/sleep', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_sleep_command( + command: SleepCommand, + instance_id: str = Path(..., description='ID of the target instance.'), +): + """Executes a sleep command on the specified AttackMate instance.""" + logger.info(f"Received sleep command request for instance {instance_id}.") + instance = get_attackmate_instance(instance_id) + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@app.post('/instances/{instance_id}/debug', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_debug_command( + command: DebugCommand, + instance_id: str = Path(..., description='ID of the target instance.'), +): + """Executes a debug command on the specified AttackMate instance.""" + logger.info(f"Received debug command request for instance {instance_id}.") + instance = get_attackmate_instance(instance_id) + attackmate_result = await run_command_on_instance(instance, command) + # Debug command might trigger SystemExit if command.exit is True + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@app.post('/instances/{instance_id}/setvar', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_setvar_command( + command: SetVarCommand, + instance_id: str = Path(..., description='ID of the target instance.'), +): + """Executes a setvar command on the specified AttackMate instance.""" + logger.info(f"Received setvar command request for instance {instance_id}.") + instance = get_attackmate_instance(instance_id) + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@app.post('/instances/{instance_id}/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_mktemp_command( + command: TempfileCommand, + instance_id: str = Path(..., description='ID of the target instance.'), +): + """Executes an mktemp command (create temp file/dir) on the specified instance.""" + logger.info(f"Received mktemp command request for instance {instance_id}.") + instance = get_attackmate_instance(instance_id) + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + +# Add other command endpoints here + +if __name__ == '__main__': + logger.info('Starting AttackMate FastAPI server') + + # `uvicorn remote_rest.server:app --reload` for development + uvicorn.run(app, host='0.0.0.0', port=8000, log_config=None) # Disable default logging to enable attackmate internal logging? how to have both + + # TODO sort out logs for different instances + # TODO return logs to caller + # TODO limit max. concurent instance number + # TODO concurrency for several instances? + # TODO add authentication + # TODO queue requests for instances + # TODO dynamic configuration of attackmate config + # TODO make logging (debug, json etc) configurable at runtime (endpoint or user query paramaters?) + # TODO ALLOWED_PLAYBOOK_DIR -> define in and load from configs + # TODO add swagger examples + # TODO generate OpenAPI schema + # TODO seperate router modules? From 761d5714d9834843d708a109440b0b167a70ccbd Mon Sep 17 00:00:00 2001 From: kali Date: Sat, 3 May 2025 00:33:40 +0200 Subject: [PATCH 02/49] create routers, create global state with DI --- remote_rest/{__int__.py => __init__.py} | 0 remote_rest/client.py | 8 +- remote_rest/main.py | 109 +++++++ remote_rest/routers/__init__.py | 0 remote_rest/routers/commands.py | 113 +++++++ remote_rest/routers/instances.py | 57 ++++ remote_rest/routers/playbooks.py | 123 ++++++++ remote_rest/schemas.py | 35 +++ remote_rest/server.py | 396 ------------------------ remote_rest/state.py | 33 ++ remote_rest/utils.py | 19 ++ 11 files changed, 493 insertions(+), 400 deletions(-) rename remote_rest/{__int__.py => __init__.py} (100%) create mode 100644 remote_rest/routers/__init__.py create mode 100644 remote_rest/routers/commands.py create mode 100644 remote_rest/routers/instances.py create mode 100644 remote_rest/routers/playbooks.py create mode 100644 remote_rest/schemas.py create mode 100644 remote_rest/state.py create mode 100644 remote_rest/utils.py diff --git a/remote_rest/__int__.py b/remote_rest/__init__.py similarity index 100% rename from remote_rest/__int__.py rename to remote_rest/__init__.py diff --git a/remote_rest/client.py b/remote_rest/client.py index 8c1715f1..a71f2313 100644 --- a/remote_rest/client.py +++ b/remote_rest/client.py @@ -1,11 +1,11 @@ -import httpx import argparse -import yaml +import json import logging import sys -import json -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List, Optional +import httpx +import yaml logging.basicConfig(level=logging.INFO, format='%(asctime)s - CLIENT - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) diff --git a/remote_rest/main.py b/remote_rest/main.py index e69de29b..60d886b5 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -0,0 +1,109 @@ +import uvicorn +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator +from attackmate.execexception import ExecException + +from src.attackmate.playbook_parser import parse_config + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from src.attackmate.logging_setup import (initialize_json_logger, + initialize_logger, + initialize_output_logger) + + +from remote_rest.routers import commands, instances, playbooks +import remote_rest.state as state + +# Logging +initialize_logger(debug=True, append_logs=False) +initialize_output_logger(debug=True, append_logs=False) +initialize_json_logger(json=True, append_logs=False) +logger = logging.getLogger('attackmate_api') # specific logger for the API +# TODO make this configurable via request, even also per attackmate instance? +logger.setLevel(logging.DEBUG) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + # Code to run before the application starts accepting requests + logger.info('AttackMate API starting up (lifespan)...') + try: + # Load global config on startup and assign to the variable in state.py + loaded_config = parse_config(config_file=None, logger=logger) + if loaded_config: + state.attackmate_config = loaded_config + logger.info('Global AttackMate configuration loaded.') + else: + raise RuntimeError('Failed to load essential AttackMate configuration (parse_config returned None).') + # Initialize the INSTANCES dict (it's already defined globally in state.py) + state.INSTANCES.clear() + logger.info('Instances dictionary initialized.') + # any other async startup tasks ? + + except Exception as e: + logger.critical(f"Failed to initialize during startup lifespan: {e}", exc_info=True) + raise RuntimeError(f"Failed to initialize application state: {e}") from e + + yield # Application runs here + + # Code to run when the application is shutting down + logger.warning('AttackMate API shutting down (lifespan)... Cleaning up instances...') + instance_ids = list(state.INSTANCES.keys()) + for instance_id in instance_ids: + instance = state.INSTANCES.pop(instance_id, None) + if instance: + logger.info(f"Cleaning up instance {instance_id}...") + try: + # blocking? + instance.clean_session_stores() + instance.pm.kill_or_wait_processes() + except Exception as e: + logger.error(f"Error cleaning up instance {instance_id}: {e}", exc_info=True) + logger.info('Instance cleanup complete (lifespan).') + + +app = FastAPI( + title='AttackMate API', + description='API for remote control of AttackMate instances and playbook execution.', + version='1.0.0', + lifespan=lifespan) + +# Include Routers +app.include_router(instances.router, prefix='/instances') +app.include_router(playbooks.router) +app.include_router(commands.router) + + +# Exception Handling +@app.exception_handler(ExecException) +async def attackmate_execution_exception_handler(request: Request, exc: ExecException): + logger.error(f"AttackMate Execution Exception: {exc}") + return JSONResponse( + status_code=400, + content={ + 'detail': 'AttackMate command execution failed', + 'error_message': str(exc), + 'instance_id': None + }, + ) + + +@app.exception_handler(Exception) +async def generic_exception_handler(request: Request, exc: Exception): + if isinstance(exc, SystemExit): + logger.error(f"Command triggered SystemExit with code {exc.code}") + return JSONResponse( + status_code=400, # client-side error pattern + content={ + 'detail': 'Command execution led to termination request', + 'error_message': f"SystemExit triggered (likely due to error condition like 'exit_on_error' or 'error_if'). Exit code: {exc.code}", + 'instance_id': None + }, + ) + # Re-raise other exceptions for specific hanfling? + raise exc + +if __name__ == '__main__': + uvicorn.run('remote_rest.main:app', host='0.0.0.0', port=8000, reload=True, log_config=None,) diff --git a/remote_rest/routers/__init__.py b/remote_rest/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py new file mode 100644 index 00000000..99d66767 --- /dev/null +++ b/remote_rest/routers/commands.py @@ -0,0 +1,113 @@ +import logging +from attackmate.attackmate import AttackMate +from attackmate.schemas.base import BaseCommand +from fastapi import APIRouter, Depends, HTTPException, Path +from src.attackmate.result import Result as AttackMateResult +from src.attackmate.schemas.debug import DebugCommand +from src.attackmate.schemas.setvar import SetVarCommand +from src.attackmate.schemas.shell import ShellCommand +from src.attackmate.schemas.sleep import SleepCommand +from src.attackmate.schemas.tempfile import TempfileCommand +from src.attackmate.execexception import ExecException + +from remote_rest.schemas import CommandResultModel, ExecutionResponseModel +from remote_rest.utils import varstore_to_state_model + +from ..state import get_instance_by_id + +# ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE + + +router = APIRouter(prefix='/instances/{instance_id}', tags=['Commands']) +logger = logging.getLogger(__name__) + + +async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: + """Runs a command on a given AttackMate instance.""" + try: + logger.info(f"Executing command type '{command_data.type}' on instance") # type: ignore + # TODO does this work? need to pass command class object here? + result = instance.run_command(command_data) + logger.info(f"Command execution finished. RC: {result.returncode}") + return result + except (ExecException, SystemExit) as e: + logger.error(f"AttackMate execution error: {e}", exc_info=True) + raise e + except Exception as e: + logger.error(f"Unexpected error during instance.run_command: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error during command execution: {e}") + + +# Command Endpoints +@router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_shell_command( + command: ShellCommand, + instance_id: str = Path(...), + instance: AttackMate = Depends(get_instance_by_id) +): + """Executes a shell command on the specified AttackMate instance.""" + attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands + + # response + result_model = CommandResultModel( + success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), # Success if RC 0 or None (background) + stdout=attackmate_result.stdout, + returncode=attackmate_result.returncode + ) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_sleep_command( + command: SleepCommand, + instance_id: str = Path(...), + instance: AttackMate = Depends(get_instance_by_id) +): + """Executes a sleep command on the specified AttackMate instance.""" + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_debug_command( + command: DebugCommand, + instance_id: str = Path(...), + instance: AttackMate = Depends(get_instance_by_id) +): + """Executes a debug command on the specified AttackMate instance.""" + attackmate_result = await run_command_on_instance(instance, command) + # Debug command might trigger SystemExit if command.exit is True + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_setvar_command( + command: SetVarCommand, + instance_id: str = Path(...), + instance: AttackMate = Depends(get_instance_by_id) +): + """Executes a setvar command on the specified AttackMate instance.""" + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + + +@router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) +async def execute_mktemp_command( + command: TempfileCommand, + instance_id: str = Path(...), + instance: AttackMate = Depends(get_instance_by_id) +): + """Executes an mktemp command (create temp file/dir) on the specified instance.""" + attackmate_result = await run_command_on_instance(instance, command) + result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + state_model = varstore_to_state_model(instance.varstore) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + +# Add other command endpoints here diff --git a/remote_rest/routers/instances.py b/remote_rest/routers/instances.py new file mode 100644 index 00000000..f352751d --- /dev/null +++ b/remote_rest/routers/instances.py @@ -0,0 +1,57 @@ +from typing import Dict +import uuid +import logging +from fastapi import APIRouter, HTTPException, Path, Depends +from src.attackmate.attackmate import AttackMate +from src.attackmate.schemas.config import Config + +from remote_rest.schemas import (InstanceCreationResponse, + VariableStoreStateModel) + +from remote_rest.utils import varstore_to_state_model + +from ..state import get_instances_dict, get_instance_by_id, get_attackmate_config + +router = APIRouter(tags=['Instances']) +logger = logging.getLogger(__name__) + + +@router.post('', response_model=InstanceCreationResponse) +async def create_new_instance( + instances: Dict[str, AttackMate] = Depends(get_instances_dict), + config: Config = Depends(get_attackmate_config) +): + """Creates a new persistent AttackMate instance and returns its ID.""" + instance_id = str(uuid.uuid4()) + logger.info(f"Creating instance ID: {instance_id}") + try: + instance = AttackMate(playbook=None, config=config, varstore=None) + instances[instance_id] = instance # Modify injected dict + logger.info(f"Instance {instance_id} created.") + return InstanceCreationResponse(instance_id=instance_id, message='Instance created.') + except Exception as e: + logger.error(f"Failed to create instance {instance_id}: {e}", exc_info=True) + if instance_id in instances: + del instances[instance_id] + raise HTTPException(status_code=500, detail='Failed to create instance.') + + +@router.delete('/{instance_id}', status_code=204) +async def delete_instance_route( + instance_id: str = Path(...), + instances: Dict[str, AttackMate] = Depends(get_instances_dict) +): + instance = instances.pop(instance_id, None) + if not instance: + raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") + logger.info(f"Deleting instance {instance_id}...") + try: + instance.clean_session_stores() + instance.pm.kill_or_wait_processes() + except Exception as e: + logger.error(f"Error during cleanup: {e}", exc_info=True) + + +@router.get('/{instance_id}/state', response_model=VariableStoreStateModel) +async def get_instance_state(instance: AttackMate = Depends(get_instance_by_id)): + return varstore_to_state_model(instance.varstore) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py new file mode 100644 index 00000000..b602c000 --- /dev/null +++ b/remote_rest/routers/playbooks.py @@ -0,0 +1,123 @@ +import logging +from fastapi import APIRouter, HTTPException, Body +from pydantic import ValidationError +from attackmate.schemas.playbook import Playbook +from remote_rest.schemas import PlaybookResponseModel, PlaybookFileRequest +from remote_rest.utils import varstore_to_state_model +from src.attackmate.attackmate import AttackMate +from src.attackmate.playbook_parser import parse_playbook +from .. state import attackmate_config +import yaml +import os + +router = APIRouter(prefix='/playbooks', tags=['Playbooks']) +logger = logging.getLogger(__name__) +ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST and be configured securely + + +# Playbook Execution +@router.post('/execute/yaml', response_model=PlaybookResponseModel) +async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml')): + """ + Executes a playbook provided as YAML content in the request body. + Use a transient AttackMate instance. + """ + logger.info('Received request to execute playbook from YAML content.') + am_instance = None + try: + playbook_dict = yaml.safe_load(playbook_yaml) + if not playbook_dict: + raise ValueError('Received empty or invalid playbook YAML content.') + playbook = Playbook.model_validate(playbook_dict) + + logger.info('Creating transient AttackMate instance...') + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. return code {return_code}") + return PlaybookResponseModel( + success=(return_code == 0), + message='Playbook execution finished.', + final_state=final_state + ) + except (yaml.YAMLError, ValidationError, ValueError) as e: + logger.error(f"Playbook validation/parsing error: {e}") + raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') + try: + am_instance.clean_session_stores() + am_instance.pm.kill_or_wait_processes() + except Exception as cleanup_e: + logger.error(f"Error cleaning transient instance: {cleanup_e}", exc_info=True) + + +@router.post('/execute/file', response_model=PlaybookResponseModel) +async def execute_playbook_from_file(request_body: PlaybookFileRequest): + """ + Executes a playbook located at a specific path *on the server*. + Uses a transient AttackMate instance. + """ + # TODO ensure this only executes playbooks in certain locations, not arbitrary code -> read up on path traversal + logger.info(f"Received request to execute playbook from file: {request_body.file_path}") + try: + # base directory exists + if not os.path.isdir(ALLOWED_PLAYBOOK_DIR): + logger.error(f"Configuration error: ALLOWED_PLAYBOOK_DIR '{ALLOWED_PLAYBOOK_DIR}' does not exist.") + raise HTTPException(status_code=500, detail='Server configuration error: Playbook directory not found.') + + requested_path = os.path.normpath(request_body.file_path) + # Disallow absolute paths or paths trying to go up directories + if os.path.isabs(requested_path) or requested_path.startswith('..'): + raise ValueError('Invalid playbook path specified.') + + full_path = os.path.join(ALLOWED_PLAYBOOK_DIR, requested_path) + # Final check: ensure the resolved path is still within the allowed directory + if not os.path.abspath(full_path).startswith(os.path.abspath(ALLOWED_PLAYBOOK_DIR)): + raise ValueError('Invalid playbook path specified (path traversal attempt ).') + + # Check if the file exists + if not os.path.isfile(full_path): + raise FileNotFoundError(f"Playbook file not found atpath: {full_path}") + + except (ValueError, FileNotFoundError) as e: + logger.error(f"Invalid or non-existent playbook path requested: {request_body.file_path} -> {e}") + raise HTTPException(status_code=400, detail=f"Invalid or non-existent playbook file path: {e}") + except Exception as e: + logger.error(f"Error processing playbook path: {e}", exc_info=True) + raise HTTPException(status_code=500, detail='Server error processing file path.') + + am_instance = None + try: + # Parse the playbook file + logger.info(f"Parsing playbook from: {full_path}") + playbook = parse_playbook(full_path, logger) + + logger.info('Creating transient AttackMate instance') + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. RC: {return_code}") + return PlaybookResponseModel( + success=(return_code == 0), + message=f"Playbook '{request_body.file_path}' execution finished.", + final_state=final_state + ) + except (ValidationError, ValueError) as e: + logger.error(f"Playbook validation error from file '{full_path}': {e}") + raise HTTPException(status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') + try: + am_instance.clean_session_stores() + am_instance.pm.kill_or_wait_processes() + except Exception as e: + logger.error(f"Cleanup error: {e}") diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py new file mode 100644 index 00000000..969c1502 --- /dev/null +++ b/remote_rest/schemas.py @@ -0,0 +1,35 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class VariableStoreStateModel(BaseModel): + variables: Dict[str, Any] = {} + + +class CommandResultModel(BaseModel): + success: bool + stdout: Optional[str] = None + returncode: Optional[int] = None + error_message: Optional[str] = None + + +class ExecutionResponseModel(BaseModel): + result: CommandResultModel + state: VariableStoreStateModel + instance_id: Optional[str] = None + + +class PlaybookResponseModel(BaseModel): + success: bool + message: str + final_state: Optional[VariableStoreStateModel] = None + + +class InstanceCreationResponse(BaseModel): + instance_id: str + message: str + + +class PlaybookFileRequest(BaseModel): + file_path: str = Field(..., description='Path to the playbook file RELATIVE to a predefined server directory.') diff --git a/remote_rest/server.py b/remote_rest/server.py index 7e65b7e1..3a3e5def 100644 --- a/remote_rest/server.py +++ b/remote_rest/server.py @@ -1,404 +1,8 @@ -import uvicorn -import logging -import uuid -import yaml -import os -from fastapi import FastAPI, HTTPException, Body, Path, Request -from fastapi.responses import JSONResponse -from pydantic import Field, ValidationError, BaseModel -from typing import Dict, Any, Optional -from attackmate.schemas.base import BaseCommand -from src.attackmate.attackmate import AttackMate -from src.attackmate.playbook_parser import parse_config, parse_playbook -from src.attackmate.logging_setup import initialize_logger, initialize_output_logger, initialize_json_logger -from src.attackmate.schemas.config import Config -from src.attackmate.schemas.playbook import Playbook -from src.attackmate.variablestore import VariableStore -from src.attackmate.result import Result as AttackMateResult -from src.attackmate.execexception import ExecException -from src.attackmate.schemas.shell import ShellCommand -from src.attackmate.schemas.sleep import SleepCommand -from src.attackmate.schemas.debug import DebugCommand -from src.attackmate.schemas.setvar import SetVarCommand -from src.attackmate.schemas.tempfile import TempfileCommand -# ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE -class VariableStoreStateModel(BaseModel): - variables: Dict[str, Any] = {} - - -class CommandResultModel(BaseModel): - success: bool - stdout: Optional[str] = None - returncode: Optional[int] = None - error_message: Optional[str] = None - - -class ExecutionResponseModel(BaseModel): - result: CommandResultModel - state: VariableStoreStateModel - instance_id: Optional[str] = None - - -class PlaybookResponseModel(BaseModel): - success: bool - message: str - final_state: Optional[VariableStoreStateModel] = None - - -class InstanceCreationResponse(BaseModel): - instance_id: str - message: str - - -class PlaybookFileRequest(BaseModel): - file_path: str = Field(..., description='Path to the playbook file RELATIVE to a predefined server directory.') - - -# Logging -initialize_logger(debug=True, append_logs=False) -initialize_output_logger(debug=True, append_logs=False) -initialize_json_logger(json=True, append_logs=False) -logger = logging.getLogger('attackmate_api') # specific logger for the API -# TODO make this configurable via request, even also per attackmate instance? -logger.setLevel(logging.DEBUG) - - -# This holds persistent AttackMate instances -INSTANCES: Dict[str, AttackMate] = {} - -# load config -# TODO pass/modify configs in the request -try: - attackmate_config: Config = parse_config(config_file=None, logger=logger) - logger.info('Global AttackMate configuration loaded.') -except Exception as e: - logger.error(f"Failed to load AttackMate config on startup: {e}", exc_info=True) - exit(1) - - -# HElpers - -def get_attackmate_instance(instance_id: str) -> AttackMate: - """Retrieves an instance""" - instance = INSTANCES.get(instance_id) - if not instance: - logger.warning(f"AttackMate instance '{instance_id}' not found.") - raise HTTPException(status_code=404, detail=f"AttackMate instance '{instance_id}' not found.") - return instance - - -def create_persistent_instance() -> str: - """Creates a new persistent AttackMate instance and returns its ID.""" - instance_id = str(uuid.uuid4()) - logger.info(f"Creating new persistent AttackMate instance with ID: {instance_id}") - try: - # Create with empty playbook/vars - instance = AttackMate(playbook=None, config=attackmate_config, varstore=None) - INSTANCES[instance_id] = instance - logger.info(f"Instance {instance_id} created successfully.") - return instance_id - except Exception as e: - logger.error(f"Failed to create persistent instance {instance_id}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail='Failed to create AttackMate instance.') - - -def varstore_to_state_model(varstore: VariableStore) -> VariableStoreStateModel: - """Converts AttackMate VariableStore to Pydantic VariableStoreStateModel.""" - combined_vars: Dict[str, Any] = {} - combined_vars.update(varstore.variables) - combined_vars.update(varstore.lists) - return VariableStoreStateModel(variables=combined_vars) - - -async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: - """Runs a command on a given AttackMate instance.""" - try: - logger.info(f"Executing command type '{command_data.type}' on instance") # type: ignore - # TODO does this work? need to pass command class object here? - result = instance.run_command(command_data) - logger.info(f"Command execution finished. RC: {result.returncode}") - return result - except (ExecException, SystemExit) as e: - logger.error(f"AttackMate execution error: {e}", exc_info=True) - raise e - except Exception as e: - logger.error(f"Unexpected error during instance.run_command: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error during command execution: {e}") - - -app = FastAPI( - title='AttackMate REST API', - description='API for remote control of AttackMate instances and playbook execution.', - version='1.0.0' -) - - -# Error handling -# --- Error Handling --- -@app.exception_handler(ExecException) -async def attackmate_execution_exception_handler(request: Request, exc: ExecException): - logger.error(f"AttackMate Execution Exception: {exc}") - return JSONResponse( - status_code=400, # bad request? - content={ - 'detail': 'AttackMate command execution failed', - 'error_message': str(exc), - 'instance_id': None - }, - ) - - -@app.exception_handler(Exception) -async def generic_exception_handler(request: Request, exc: Exception): - if isinstance(exc, SystemExit): - logger.error(f"Command triggered SystemExit with code {exc.code}") - return JSONResponse( - status_code=400, # client-side error pattern - content={ - 'detail': 'Command execution led to termination request', - 'error_message': f"SystemExit triggered (likely due to error condition like 'exit_on_error' or 'error_if'). Exit code: {exc.code}", - 'instance_id': None - }, - ) - # Re-raise other exceptions for specific hanfling? - raise exc - - -# Endpoints - -# Playbook Execution -@app.post('/playbooks/execute/yaml', response_model=PlaybookResponseModel, tags=['Playbooks']) -async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml')): - """ - Executes a playbook provided as YAML content in the request body. - Use a transient AttackMate instance. - """ - logger.info('Received request to execute playbook from YAML content.') - am_instance = None - try: - playbook_dict = yaml.safe_load(playbook_yaml) - if not playbook_dict: - raise ValueError('Received empty or invalid playbook YAML content.') - playbook = Playbook.model_validate(playbook_dict) - - logger.info('Creating transient AttackMate instance...') - am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) - return_code = am_instance.main() - final_state = varstore_to_state_model(am_instance.varstore) - logger.info(f"Transient playbook execution finished. return code {return_code}") - return PlaybookResponseModel( - success=(return_code == 0), - message='Playbook execution finished.', - final_state=final_state - ) - except (yaml.YAMLError, ValidationError, ValueError) as e: - logger.error(f"Playbook validation/parsing error: {e}") - raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") - except Exception as e: - logger.error(f"Unexpected error during playbook execution: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") - finally: - if am_instance: - logger.info('Cleaning up transient playbook instance.') - try: - am_instance.clean_session_stores() - am_instance.pm.kill_or_wait_processes() - except Exception as cleanup_e: - logger.error(f"Error cleaning transient instance: {cleanup_e}", exc_info=True) - - -@app.post('/playbooks/execute/file', response_model=PlaybookResponseModel, tags=['Playbooks']) -async def execute_playbook_from_file(request_body: PlaybookFileRequest): - """ - Executes a playbook located at a specific path *on the server*. - Uses a transient AttackMate instance. - """ - # TODO ensure this only executes playbooks in certain locations, not arbitrary code -> read up on path traversal - logger.info(f"Received request to execute playbook from file: {request_body.file_path}") - - # Define a secure base directory where playbooks are allowed - ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST and be configured securely - try: - # base directory exists - if not os.path.isdir(ALLOWED_PLAYBOOK_DIR): - logger.error(f"Configuration error: ALLOWED_PLAYBOOK_DIR '{ALLOWED_PLAYBOOK_DIR}' does not exist.") - raise HTTPException(status_code=500, detail='Server configuration error: Playbook directory not found.') - - requested_path = os.path.normpath(request_body.file_path) - # Disallow absolute paths or paths trying to go up directories - if os.path.isabs(requested_path) or requested_path.startswith('..'): - raise ValueError('Invalid playbook path specified.') - - full_path = os.path.join(ALLOWED_PLAYBOOK_DIR, requested_path) - # Final check: ensure the resolved path is still within the allowed directory - if not os.path.abspath(full_path).startswith(os.path.abspath(ALLOWED_PLAYBOOK_DIR)): - raise ValueError('Invalid playbook path specified (path traversal attempt ).') - - # Check if the file exists - if not os.path.isfile(full_path): - raise FileNotFoundError(f"Playbook file not found atpath: {full_path}") - - except (ValueError, FileNotFoundError) as e: - logger.error(f"Invalid or non-existent playbook path requested: {request_body.file_path} -> {e}") - raise HTTPException(status_code=400, detail=f"Invalid or non-existent playbook file path: {e}") - except Exception as e: - logger.error(f"Error processing playbook path: {e}", exc_info=True) - raise HTTPException(status_code=500, detail='Server error processing file path.') - - am_instance = None - try: - # Parse the playbook file - logger.info(f"Parsing playbook from: {full_path}") - playbook = parse_playbook(full_path, logger) - - logger.info('Creating transient AttackMate instance') - am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) - return_code = am_instance.main() - final_state = varstore_to_state_model(am_instance.varstore) - logger.info(f"Transient playbook execution finished. RC: {return_code}") - return PlaybookResponseModel( - success=(return_code == 0), - message=f"Playbook '{request_body.file_path}' execution finished.", - final_state=final_state - ) - except (ValidationError, ValueError) as e: - logger.error(f"Playbook validation error from file '{full_path}': {e}") - raise HTTPException(status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") - except Exception as e: - logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") - finally: - if am_instance: - logger.info('Cleaning up transient playbook instance.') - try: - am_instance.clean_session_stores() - am_instance.pm.kill_or_wait_processes() - except Exception as e: - logger.error(f"Cleanup error: {e}") - - -# Attackmate Instances - -@app.post('/instances', response_model=InstanceCreationResponse, tags=['Instances']) -async def create_new_instance(): - """Creates a new persistent AttackMate instance and returns its ID.""" - instance_id = create_persistent_instance() - return InstanceCreationResponse(instance_id=instance_id, message='AttackMate instance created successfully.') - - -@app.delete('/instances/{instance_id}', status_code=204, tags=['Instances']) -async def delete_instance(instance_id: str = Path(..., description='ID of the instance to delete.')): - """Deletes a persistent AttackMate instance.""" - instance = INSTANCES.pop(instance_id, None) - if not instance: - raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") - logger.info(f"Deleting instance {instance_id}...") - try: - instance.clean_session_stores() - instance.pm.kill_or_wait_processes() - logger.info(f"Instance {instance_id} cleaned up and deleted.") - except Exception as e: - logger.error(f"Error during cleanup while deleting instance {instance_id}: {e}", exc_info=True) - return - - -@app.get('/instances/{instance_id}/state', response_model=VariableStoreStateModel, tags=['Instances']) -async def get_instance_state(instance_id: str = Path(..., description='ID of the instance.')): - """Gets the current variable store state for an instance.""" - instance = get_attackmate_instance(instance_id) - return varstore_to_state_model(instance.varstore) - - -# Command Endpoints -@app.post('/instances/{instance_id}/shell', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_shell_command( - command: ShellCommand, - instance_id: str = Path(..., description='ID of the target instance.'), -): - """Executes a shell command on the specified AttackMate instance.""" - logger.info(f"Received shell command request for instance {instance_id}.") - instance = get_attackmate_instance(instance_id) # Raise 404 if not found - attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands - - # response - result_model = CommandResultModel( - success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), # Success if RC 0 or None (background) - stdout=attackmate_result.stdout, - returncode=attackmate_result.returncode - ) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) - - -@app.post('/instances/{instance_id}/sleep', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_sleep_command( - command: SleepCommand, - instance_id: str = Path(..., description='ID of the target instance.'), -): - """Executes a sleep command on the specified AttackMate instance.""" - logger.info(f"Received sleep command request for instance {instance_id}.") - instance = get_attackmate_instance(instance_id) - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) - - -@app.post('/instances/{instance_id}/debug', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_debug_command( - command: DebugCommand, - instance_id: str = Path(..., description='ID of the target instance.'), -): - """Executes a debug command on the specified AttackMate instance.""" - logger.info(f"Received debug command request for instance {instance_id}.") - instance = get_attackmate_instance(instance_id) - attackmate_result = await run_command_on_instance(instance, command) - # Debug command might trigger SystemExit if command.exit is True - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) - - -@app.post('/instances/{instance_id}/setvar', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_setvar_command( - command: SetVarCommand, - instance_id: str = Path(..., description='ID of the target instance.'), -): - """Executes a setvar command on the specified AttackMate instance.""" - logger.info(f"Received setvar command request for instance {instance_id}.") - instance = get_attackmate_instance(instance_id) - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) - - -@app.post('/instances/{instance_id}/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_mktemp_command( - command: TempfileCommand, - instance_id: str = Path(..., description='ID of the target instance.'), -): - """Executes an mktemp command (create temp file/dir) on the specified instance.""" - logger.info(f"Received mktemp command request for instance {instance_id}.") - instance = get_attackmate_instance(instance_id) - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) - -# Add other command endpoints here - -if __name__ == '__main__': - logger.info('Starting AttackMate FastAPI server') - - # `uvicorn remote_rest.server:app --reload` for development - uvicorn.run(app, host='0.0.0.0', port=8000, log_config=None) # Disable default logging to enable attackmate internal logging? how to have both - # TODO sort out logs for different instances # TODO return logs to caller # TODO limit max. concurent instance number diff --git a/remote_rest/state.py b/remote_rest/state.py new file mode 100644 index 00000000..b4c88edb --- /dev/null +++ b/remote_rest/state.py @@ -0,0 +1,33 @@ +from typing import Dict, Optional + +from fastapi import Depends, HTTPException, Path +from src.attackmate.attackmate import AttackMate +from src.attackmate.schemas.config import Config + +# Define shared state variables here +INSTANCES: Dict[str, AttackMate] = {} +attackmate_config: Optional[Config] = None + + +def get_instances_dict() -> Dict[str, AttackMate]: + """Dependency to get the shared INSTANCES dictionary.""" + # Simply returns the global dict reference + return INSTANCES + + +def get_attackmate_config() -> Config: + """Dependency to get the shared AttackMate configuration.""" + if attackmate_config is None: + raise RuntimeError('Server configuration is not available.') + return attackmate_config + + +def get_instance_by_id( + instance_id: str = Path(...), + instances: Dict[str, AttackMate] = Depends(get_instances_dict) +) -> AttackMate: + """Dependency to get a specific AttackMate instance, raising 404 if not found.""" + instance = instances.get(instance_id) + if not instance: + raise HTTPException(status_code=404, detail=f"AttackMate instance '{instance_id}' not found.") + return instance diff --git a/remote_rest/utils.py b/remote_rest/utils.py new file mode 100644 index 00000000..3b355d20 --- /dev/null +++ b/remote_rest/utils.py @@ -0,0 +1,19 @@ +import logging +from typing import Any, Dict + +from src.attackmate.variablestore import VariableStore + +from remote_rest.schemas import VariableStoreStateModel + +logger = logging.getLogger(__name__) + + +def varstore_to_state_model(varstore: VariableStore) -> VariableStoreStateModel: + """Converts AttackMate VariableStore to Pydantic VariableStoreStateModel.""" + if not isinstance(varstore, VariableStore): + logger.error(f"Invalid type passed to varstore_to_state_model: {type(varstore)}") + return VariableStoreStateModel(variables={'error': 'Internal state error'}) # Prevent crashes + combined_vars: Dict[str, Any] = {} + combined_vars.update(varstore.variables) + combined_vars.update(varstore.lists) + return VariableStoreStateModel(variables=combined_vars) From 9969303fd060a0162ed2cc193e7eb21b67ade759 Mon Sep 17 00:00:00 2001 From: kali Date: Sat, 3 May 2025 00:35:12 +0200 Subject: [PATCH 03/49] isort --- remote_rest/main.py | 14 ++++++-------- remote_rest/routers/commands.py | 11 ++++++----- remote_rest/routers/instances.py | 15 ++++++++------- remote_rest/routers/playbooks.py | 13 ++++++++----- remote_rest/state.py | 1 + remote_rest/utils.py | 3 +-- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/remote_rest/main.py b/remote_rest/main.py index 60d886b5..08e9bc75 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -1,20 +1,18 @@ -import uvicorn import logging from contextlib import asynccontextmanager from typing import AsyncGenerator -from attackmate.execexception import ExecException - -from src.attackmate.playbook_parser import parse_config +import uvicorn from fastapi import FastAPI, Request from fastapi.responses import JSONResponse + +import remote_rest.state as state +from attackmate.execexception import ExecException +from remote_rest.routers import commands, instances, playbooks from src.attackmate.logging_setup import (initialize_json_logger, initialize_logger, initialize_output_logger) - - -from remote_rest.routers import commands, instances, playbooks -import remote_rest.state as state +from src.attackmate.playbook_parser import parse_config # Logging initialize_logger(debug=True, append_logs=False) diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 99d66767..2a9087b6 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -1,17 +1,18 @@ import logging + +from fastapi import APIRouter, Depends, HTTPException, Path + from attackmate.attackmate import AttackMate from attackmate.schemas.base import BaseCommand -from fastapi import APIRouter, Depends, HTTPException, Path +from remote_rest.schemas import CommandResultModel, ExecutionResponseModel +from remote_rest.utils import varstore_to_state_model +from src.attackmate.execexception import ExecException from src.attackmate.result import Result as AttackMateResult from src.attackmate.schemas.debug import DebugCommand from src.attackmate.schemas.setvar import SetVarCommand from src.attackmate.schemas.shell import ShellCommand from src.attackmate.schemas.sleep import SleepCommand from src.attackmate.schemas.tempfile import TempfileCommand -from src.attackmate.execexception import ExecException - -from remote_rest.schemas import CommandResultModel, ExecutionResponseModel -from remote_rest.utils import varstore_to_state_model from ..state import get_instance_by_id diff --git a/remote_rest/routers/instances.py b/remote_rest/routers/instances.py index f352751d..7fec98aa 100644 --- a/remote_rest/routers/instances.py +++ b/remote_rest/routers/instances.py @@ -1,16 +1,17 @@ -from typing import Dict -import uuid import logging -from fastapi import APIRouter, HTTPException, Path, Depends -from src.attackmate.attackmate import AttackMate -from src.attackmate.schemas.config import Config +import uuid +from typing import Dict + +from fastapi import APIRouter, Depends, HTTPException, Path from remote_rest.schemas import (InstanceCreationResponse, VariableStoreStateModel) - from remote_rest.utils import varstore_to_state_model +from src.attackmate.attackmate import AttackMate +from src.attackmate.schemas.config import Config -from ..state import get_instances_dict, get_instance_by_id, get_attackmate_config +from ..state import (get_attackmate_config, get_instance_by_id, + get_instances_dict) router = APIRouter(tags=['Instances']) logger = logging.getLogger(__name__) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index b602c000..9a9b7d64 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -1,14 +1,17 @@ import logging -from fastapi import APIRouter, HTTPException, Body +import os + +import yaml +from fastapi import APIRouter, Body, HTTPException from pydantic import ValidationError + from attackmate.schemas.playbook import Playbook -from remote_rest.schemas import PlaybookResponseModel, PlaybookFileRequest +from remote_rest.schemas import PlaybookFileRequest, PlaybookResponseModel from remote_rest.utils import varstore_to_state_model from src.attackmate.attackmate import AttackMate from src.attackmate.playbook_parser import parse_playbook -from .. state import attackmate_config -import yaml -import os + +from ..state import attackmate_config router = APIRouter(prefix='/playbooks', tags=['Playbooks']) logger = logging.getLogger(__name__) diff --git a/remote_rest/state.py b/remote_rest/state.py index b4c88edb..c0516994 100644 --- a/remote_rest/state.py +++ b/remote_rest/state.py @@ -1,6 +1,7 @@ from typing import Dict, Optional from fastapi import Depends, HTTPException, Path + from src.attackmate.attackmate import AttackMate from src.attackmate.schemas.config import Config diff --git a/remote_rest/utils.py b/remote_rest/utils.py index 3b355d20..5be58b04 100644 --- a/remote_rest/utils.py +++ b/remote_rest/utils.py @@ -1,9 +1,8 @@ import logging from typing import Any, Dict -from src.attackmate.variablestore import VariableStore - from remote_rest.schemas import VariableStoreStateModel +from src.attackmate.variablestore import VariableStore logger = logging.getLogger(__name__) From 86993e62e147e0340a6a648f463aa3800987870e Mon Sep 17 00:00:00 2001 From: kali Date: Sat, 3 May 2025 00:46:06 +0200 Subject: [PATCH 04/49] add readme --- remote_rest/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/remote_rest/README.md b/remote_rest/README.md index e69de29b..0cd4fcd5 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -0,0 +1,21 @@ +pip install fastapi uvicorn httpx PyYAML pydantic + + +uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload + + + + + +[] TODO sort out logs for different instances +[] TODO return logs to caller +[] TODO limit max. concurent instance number +[] TODO concurrency for several instances? +[] TODO add authentication +[] TODO queue requests for instances +[] TODO dynamic configuration of attackmate config +[] TODO make logging (debug, json etc) configurable at runtime (endpoint or user query paramaters?) +[] TODO ALLOWED_PLAYBOOK_DIR -> define in and load from configs +[] TODO add swagger examples +[] TODO generate/check OpenAPI schema +[x] TODO seperate router modules? From 7f5f2505d2893452ea71cae52333f5db26eeb7c2 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 10:43:34 +0200 Subject: [PATCH 05/49] add fastapi to dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f96534e5..f3311232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ dependencies = [ "httpx", "httpx[http2]", "vncdotool", - "pytest-mock" + "pytest-mock", + "fastapi" ] dynamic = ["version"] From 5dc058c5c54ab9dc2568300576105cb615b7f956 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 13:46:31 +0200 Subject: [PATCH 06/49] use only one persistent instance --- remote_rest/README.md | 4 - remote_rest/client.py | 162 +++++++++++++++++-------------- remote_rest/main.py | 6 +- remote_rest/routers/commands.py | 48 ++++----- remote_rest/routers/instances.py | 76 ++++++++------- remote_rest/routers/playbooks.py | 9 +- remote_rest/server.py | 17 ---- remote_rest/state.py | 10 ++ 8 files changed, 176 insertions(+), 156 deletions(-) delete mode 100644 remote_rest/server.py diff --git a/remote_rest/README.md b/remote_rest/README.md index 0cd4fcd5..8e2a4f4a 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -3,10 +3,6 @@ pip install fastapi uvicorn httpx PyYAML pydantic uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload - - - - [] TODO sort out logs for different instances [] TODO return logs to caller [] TODO limit max. concurent instance number diff --git a/remote_rest/client.py b/remote_rest/client.py index a71f2313..9c3a6680 100644 --- a/remote_rest/client.py +++ b/remote_rest/client.py @@ -26,47 +26,47 @@ def parse_key_value_pairs(items: List[str] | None) -> Dict[str, str]: return result -def create_instance(client: httpx.Client, base_url: str) -> Optional[str]: - """Requests creation of a new persistent instance.""" - url = f"{base_url}/instances" - logger.info(f"Requesting new instance creation at {url}...") - try: - response = client.post(url) - response.raise_for_status() - data = response.json() - instance_id = data.get('instance_id') - logger.info(f"Successfully created instance: {instance_id}") - print(f"Instance Created: {instance_id}") - return instance_id - except httpx.RequestError as e: - logger.error(f"HTTP Request Error creating instance: {e}") - return None - except httpx.HTTPStatusError as e: - logger.error(f"HTTP Status Error creating instance: {e.response.status_code} - {e.response.text}") - return None - except Exception as e: - logger.error(f"Unexpected error creating instance: {e}", exc_info=True) - return None - - -def delete_instance_on_server(client: httpx.Client, base_url: str, instance_id: str): - """Requests deletion of a persistent instance.""" - url = f"{base_url}/instances/{instance_id}" - logger.info(f"Requesting deletion of instance {instance_id} at {url}...") - try: - response = client.delete(url) - response.raise_for_status() - logger.info(f"Successfully requested deletion of instance: {instance_id} (Status: {response.status_code})") - print(f"Instance {instance_id} deletion requested.") - except httpx.RequestError as e: - logger.error(f"HTTP Request Error deleting instance: {e}") - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - logger.warning(f"Instance {instance_id} not found on server for deletion.") - else: - logger.error(f"HTTP Status Error deleting instance: {e.response.status_code} - {e.response.text}") - except Exception as e: - logger.error(f"Unexpected error deleting instance: {e}", exc_info=True) +# def create_instance(client: httpx.Client, base_url: str) -> Optional[str]: +# """Requests creation of a new persistent instance.""" +# url = f"{base_url}/instances" +# logger.info(f"Requesting new instance creation at {url}...") +# try: +# response = client.post(url) +# response.raise_for_status() +# data = response.json() +# instance_id = data.get('instance_id') +# logger.info(f"Successfully created instance: {instance_id}") +# print(f"Instance Created: {instance_id}") +# return instance_id +# except httpx.RequestError as e: +# logger.error(f"HTTP Request Error creating instance: {e}") +# return None +# except httpx.HTTPStatusError as e: +# logger.error(f"HTTP Status Error creating instance: {e.response.status_code} - {e.response.text}") +# return None +# except Exception as e: +# logger.error(f"Unexpected error creating instance: {e}", exc_info=True) +# return None + + +# def delete_instance_on_server(client: httpx.Client, base_url: str, instance_id: str): +# """Requests deletion of a persistent instance.""" +# url = f"{base_url}/instances/{instance_id}" +# logger.info(f"Requesting deletion of instance {instance_id} at {url}...") +# try: +# response = client.delete(url) +# response.raise_for_status() +# logger.info(f"Successfully requested deletion of instance: {instance_id} (Status: {response.status_code})") +# print(f"Instance {instance_id} deletion requested.") +# except httpx.RequestError as e: +# logger.error(f"HTTP Request Error deleting instance: {e}") +# except httpx.HTTPStatusError as e: +# if e.response.status_code == 404: +# logger.warning(f"Instance {instance_id} not found on server for deletion.") +# else: +# logger.error(f"HTTP Status Error deleting instance: {e.response.status_code} - {e.response.text}") +# except Exception as e: +# logger.error(f"Unexpected error deleting instance: {e}", exc_info=True) def get_instance_state_from_server(client: httpx.Client, base_url: str, instance_id: str): @@ -99,7 +99,8 @@ def run_playbook_yaml(client: httpx.Client, base_url: str, playbook_file: str): sys.exit(1) try: - response = client.post(url, content=playbook_yaml_content, headers={'Content-Type': 'application/yaml'}) + response = client.post(url, content=playbook_yaml_content, headers={ + 'Content-Type': 'application/yaml'}) response.raise_for_status() data = response.json() print('\n Playbook YAML Execution Result ') @@ -153,13 +154,13 @@ def run_command(client: httpx.Client, base_url: str, args): """Sends a single command to the server for execution against an instance.""" # TODO this need more special handling for sliver commands? type = args.type - instance_id = args.instance_id # Get instance ID from args - if not instance_id: - logger.error('Instance ID is required for executing commands.') - sys.exit(1) + # instance_id = args.instance_id # Get instance ID from args + # if not instance_id: + # logger.error('Instance ID is required for executing commands.') + # sys.exit(1) - url = f"{base_url}/instances/{instance_id}/{type.replace('_', '-')}" - logger.info(f"Attempting command '{type}' on instance '{instance_id}' at {url}") + url = f"{base_url}/command/{type.replace('_', '-')}" + logger.info(f"Attempting command '{type}' on instance 'default_context' at {url}") # Construct the request body dictionary (matching Pydantic model) # Exclude argparse internals @@ -187,7 +188,7 @@ def run_command(client: httpx.Client, base_url: str, args): result = data.get('result', {}) state = data.get('state', {}).get('variables', {}) - print(f"\n--- Command Result (Instance: {data.get('instance_id', instance_id)}) ---") + print('\n--- Command Result ---') print(f"Success: {result.get('success')}") print(f"Return Code: {result.get('returncode')}") print(f"Stdout:\n{result.get('stdout')}") @@ -215,27 +216,34 @@ def run_command(client: httpx.Client, base_url: str, args): # Main Execution Logic def main(): parser = argparse.ArgumentParser(description='AttackMate REST API Client') - parser.add_argument('--base-url', default='http://localhost:8000', help='Base URL of the AttackMate API server') + parser.add_argument('--base-url', default='http://localhost:8000', + help='Base URL of the AttackMate API server') subparsers = parser.add_subparsers(dest='mode', required=True, help='Operation mode') # Playbook Modes - parser_pb_yaml = subparsers.add_parser('playbook-yaml', help='Execute a playbook from a local YAML file content') + parser_pb_yaml = subparsers.add_parser( + 'playbook-yaml', help='Execute a playbook from a local YAML file content') parser_pb_yaml.add_argument('playbook_file', help='Path to the local playbook YAML file') - parser_pb_file = subparsers.add_parser('playbook-file', help='Request server execute a playbook from its filesystem') - parser_pb_file.add_argument('server_playbook_path', help='Path to the playbook file relative to the server\'s allowed directory') + parser_pb_file = subparsers.add_parser( + 'playbook-file', help='Request server execute a playbook from its filesystem') + parser_pb_file.add_argument('server_playbook_path', + help='Path to the playbook file relative to the server\'s allowed directory') # Instance Management Modes - parser_inst_create = subparsers.add_parser('instance-create', help='Create a new persistent AttackMate instance') - parser_inst_delete = subparsers.add_parser('instance-delete', help='Delete a persistent AttackMate instance') - parser_inst_delete.add_argument('instance_id', help='ID of the instance to delete') + # parser_inst_create = subparsers.add_parser( + # 'instance-create', help='Create a new persistent AttackMate instance') + # parser_inst_delete = subparsers.add_parser( + # 'instance-delete', help='Delete a persistent AttackMate instance') + # parser_inst_delete.add_argument('instance_id', help='ID of the instance to delete') parser_inst_state = subparsers.add_parser('instance-state', help='Get the state of an instance') parser_inst_state.add_argument('instance_id', help='ID of the instance') # Command Mode parser_command = subparsers.add_parser('command', help='Execute a single command on a specific instance') - parser_command.add_argument('instance_id', help='ID of the target persistent instance') - command_subparsers = parser_command.add_subparsers(dest='type', required=True, help='Specific command type') + # parser_command.add_argument('instance_id', help='ID of the target persistent instance') + command_subparsers = parser_command.add_subparsers( + dest='type', required=True, help='Specific command type') # Define Common Arguments Parser (used as parent for command types) common_args_parser = argparse.ArgumentParser(add_help=False) @@ -245,15 +253,20 @@ def main(): common_args_parser.add_argument('--loop-if', help='Regex pattern to loop on match') common_args_parser.add_argument('--loop-if-not', help='Regex pattern to loop if no match') common_args_parser.add_argument('--loop-count', type=int, help='Maximum loop iterations') - common_args_parser.add_argument('--exit-on-error', action=argparse.BooleanOptionalAction, help='Exit if command return code is non-zero') + common_args_parser.add_argument( + '--exit-on-error', action=argparse.BooleanOptionalAction, help='Exit if command return code is non-zero') common_args_parser.add_argument('--save', help='File path to save command stdout') - common_args_parser.add_argument('--background', action=argparse.BooleanOptionalAction, help='Run command in the background') - common_args_parser.add_argument('--kill-on-exit', action=argparse.BooleanOptionalAction, help='Kill process on server exit') - common_args_parser.add_argument('--metadata', action='append', help='Metadata key=value pair (repeatable)') + common_args_parser.add_argument( + '--background', action=argparse.BooleanOptionalAction, help='Run command in the background') + common_args_parser.add_argument( + '--kill-on-exit', action=argparse.BooleanOptionalAction, help='Kill process on server exit') + common_args_parser.add_argument('--metadata', action='append', + help='Metadata key=value pair (repeatable)') # Add Command Subparsers # Shell - parser_shell = command_subparsers.add_parser('shell', help='Execute shell command', parents=[common_args_parser]) + parser_shell = command_subparsers.add_parser( + 'shell', help='Execute shell command', parents=[common_args_parser]) parser_shell.add_argument('cmd', help='The command to execute') parser_shell.add_argument('--interactive', action=argparse.BooleanOptionalAction) parser_shell.add_argument('--creates-session', help='Name of shell session to create') @@ -264,27 +277,32 @@ def main(): parser_shell.add_argument('--bin', action=argparse.BooleanOptionalAction) # Sleep - parser_sleep = command_subparsers.add_parser('sleep', help='Pause execution', parents=[common_args_parser]) + parser_sleep = command_subparsers.add_parser( + 'sleep', help='Pause execution', parents=[common_args_parser]) parser_sleep.add_argument('--seconds', type=int) parser_sleep.add_argument('--min-sec', type=int) parser_sleep.add_argument('--random', action=argparse.BooleanOptionalAction) # Debug - parser_debug = command_subparsers.add_parser('debug', help='Debug output or pause', parents=[common_args_parser]) + parser_debug = command_subparsers.add_parser( + 'debug', help='Debug output or pause', parents=[common_args_parser]) parser_debug.add_argument('--varstore', action=argparse.BooleanOptionalAction) parser_debug.add_argument('--exit', action=argparse.BooleanOptionalAction) parser_debug.add_argument('--wait-for-key', action=argparse.BooleanOptionalAction) # SetVar - parser_setvar = command_subparsers.add_parser('setvar', help='Set a variable', parents=[common_args_parser]) + parser_setvar = command_subparsers.add_parser( + 'setvar', help='Set a variable', parents=[common_args_parser]) parser_setvar.add_argument('variable', help='Name of the variable to set') parser_setvar.add_argument('cmd', help='Value to assign to the variable') parser_setvar.add_argument('--encoder', help='Encoder to use') # Mktemp (Tempfile) - parser_mktemp = command_subparsers.add_parser('mktemp', help='Create temporary file/directory', parents=[common_args_parser]) + parser_mktemp = command_subparsers.add_parser( + 'mktemp', help='Create temporary file/directory', parents=[common_args_parser]) parser_mktemp.add_argument('variable', help='Variable name to store the path') - parser_mktemp.add_argument('--cmd', choices=['file', 'dir'], default='file', help='create a file or directory') + parser_mktemp.add_argument('--cmd', choices=['file', 'dir'], + default='file', help='create a file or directory') # ADD SUBPARSERS FOR ALL OTHER COMMAND TYPES HERE @@ -298,10 +316,10 @@ def main(): run_playbook_yaml(client, args.base_url, args.playbook_file) elif args.mode == 'playbook-file': run_playbook_file(client, args.base_url, args.server_playbook_path) - elif args.mode == 'instance-create': - create_instance(client, args.base_url) - elif args.mode == 'instance-delete': - delete_instance_on_server(client, args.base_url, args.instance_id) + # elif args.mode == 'instance-create': + # create_instance(client, args.base_url) + # elif args.mode == 'instance-delete': + # delete_instance_on_server(client, args.base_url, args.instance_id) elif args.mode == 'instance-state': get_instance_state_from_server(client, args.base_url, args.instance_id) elif args.mode == 'command': diff --git a/remote_rest/main.py b/remote_rest/main.py index 08e9bc75..84caba84 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse import remote_rest.state as state +from src.attackmate.attackmate import AttackMate from attackmate.execexception import ExecException from remote_rest.routers import commands, instances, playbooks from src.attackmate.logging_setup import (initialize_json_logger, @@ -34,9 +35,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: state.attackmate_config = loaded_config logger.info('Global AttackMate configuration loaded.') else: - raise RuntimeError('Failed to load essential AttackMate configuration (parse_config returned None).') + raise RuntimeError( + 'Failed to load essential AttackMate configuration (parse_config returned None).') # Initialize the INSTANCES dict (it's already defined globally in state.py) state.INSTANCES.clear() + # instantiate the Instance in the INSTANCES dict + state.INSTANCES['default_context'] = AttackMate(playbook=None, config=loaded_config, varstore=None) logger.info('Instances dictionary initialized.') # any other async startup tasks ? diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 2a9087b6..923b2544 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -1,6 +1,6 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Path +from fastapi import APIRouter, Depends, HTTPException from attackmate.attackmate import AttackMate from attackmate.schemas.base import BaseCommand @@ -14,19 +14,19 @@ from src.attackmate.schemas.sleep import SleepCommand from src.attackmate.schemas.tempfile import TempfileCommand -from ..state import get_instance_by_id +from ..state import get_persistent_instance # ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE -router = APIRouter(prefix='/instances/{instance_id}', tags=['Commands']) +router = APIRouter(prefix='/command', tags=['Commands']) logger = logging.getLogger(__name__) async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: """Runs a command on a given AttackMate instance.""" try: - logger.info(f"Executing command type '{command_data.type}' on instance") # type: ignore + logger.info(f"Executing command type '{command_data.type}' on instance") # type: ignore # TODO does this work? need to pass command class object here? result = instance.run_command(command_data) logger.info(f"Command execution finished. RC: {result.returncode}") @@ -43,72 +43,72 @@ async def run_command_on_instance(instance: AttackMate, command_data: BaseComman @router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_shell_command( command: ShellCommand, - instance_id: str = Path(...), - instance: AttackMate = Depends(get_instance_by_id) + instance: AttackMate = Depends(get_persistent_instance) ): """Executes a shell command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands # response result_model = CommandResultModel( - success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), # Success if RC 0 or None (background) + # Success if RC 0 or None (background) + success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), stdout=attackmate_result.stdout, returncode=attackmate_result.returncode ) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') @router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_sleep_command( command: SleepCommand, - instance_id: str = Path(...), - instance: AttackMate = Depends(get_instance_by_id) + instance: AttackMate = Depends(get_persistent_instance) ): """Executes a sleep command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + result_model = CommandResultModel( + success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') @router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_debug_command( command: DebugCommand, - instance_id: str = Path(...), - instance: AttackMate = Depends(get_instance_by_id) + instance: AttackMate = Depends(get_persistent_instance) ): """Executes a debug command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) # Debug command might trigger SystemExit if command.exit is True - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + result_model = CommandResultModel( + success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') @router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_setvar_command( command: SetVarCommand, - instance_id: str = Path(...), - instance: AttackMate = Depends(get_instance_by_id) + instance: AttackMate = Depends(get_persistent_instance) ): """Executes a setvar command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + result_model = CommandResultModel( + success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') @router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_mktemp_command( command: TempfileCommand, - instance_id: str = Path(...), - instance: AttackMate = Depends(get_instance_by_id) + instance: AttackMate = Depends(get_persistent_instance) ): """Executes an mktemp command (create temp file/dir) on the specified instance.""" attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel(success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) + result_model = CommandResultModel( + success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id=instance_id) + return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') # Add other command endpoints here diff --git a/remote_rest/routers/instances.py b/remote_rest/routers/instances.py index 7fec98aa..3fcf2985 100644 --- a/remote_rest/routers/instances.py +++ b/remote_rest/routers/instances.py @@ -11,48 +11,54 @@ from src.attackmate.schemas.config import Config from ..state import (get_attackmate_config, get_instance_by_id, - get_instances_dict) + get_instances_dict, get_persistent_instance) router = APIRouter(tags=['Instances']) logger = logging.getLogger(__name__) -@router.post('', response_model=InstanceCreationResponse) -async def create_new_instance( - instances: Dict[str, AttackMate] = Depends(get_instances_dict), - config: Config = Depends(get_attackmate_config) -): - """Creates a new persistent AttackMate instance and returns its ID.""" - instance_id = str(uuid.uuid4()) - logger.info(f"Creating instance ID: {instance_id}") - try: - instance = AttackMate(playbook=None, config=config, varstore=None) - instances[instance_id] = instance # Modify injected dict - logger.info(f"Instance {instance_id} created.") - return InstanceCreationResponse(instance_id=instance_id, message='Instance created.') - except Exception as e: - logger.error(f"Failed to create instance {instance_id}: {e}", exc_info=True) - if instance_id in instances: - del instances[instance_id] - raise HTTPException(status_code=500, detail='Failed to create instance.') - - -@router.delete('/{instance_id}', status_code=204) -async def delete_instance_route( - instance_id: str = Path(...), - instances: Dict[str, AttackMate] = Depends(get_instances_dict) -): - instance = instances.pop(instance_id, None) - if not instance: - raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") - logger.info(f"Deleting instance {instance_id}...") - try: - instance.clean_session_stores() - instance.pm.kill_or_wait_processes() - except Exception as e: - logger.error(f"Error during cleanup: {e}", exc_info=True) +# THIS CAN POTENTIALLY BE USED TO GNERATE NEW INSTANCES WITH IDS +# @router.post('', response_model=InstanceCreationResponse) +# async def create_new_instance( +# instances: Dict[str, AttackMate] = Depends(get_instances_dict), +# config: Config = Depends(get_attackmate_config) +# ): +# """Creates a new persistent AttackMate instance and returns its ID.""" +# instance_id = str(uuid.uuid4()) +# logger.info(f"Creating instance ID: {instance_id}") +# try: +# instance = AttackMate(playbook=None, config=config, varstore=None) +# instances[instance_id] = instance # Modify injected dict +# logger.info(f"Instance {instance_id} created.") +# return InstanceCreationResponse(instance_id=instance_id, message='Instance created.') +# except Exception as e: +# logger.error(f"Failed to create instance {instance_id}: {e}", exc_info=True) +# if instance_id in instances: +# del instances[instance_id] +# raise HTTPException(status_code=500, detail='Failed to create instance.') + + +# @router.delete('/{instance_id}', status_code=204) +# async def delete_instance_route( +# instance_id: str = Path(...), +# instances: Dict[str, AttackMate] = Depends(get_instances_dict) +# ): +# instance = instances.pop(instance_id, None) +# if not instance: +# raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") +# logger.info(f"Deleting instance {instance_id}...") +# try: +# instance.clean_session_stores() +# instance.pm.kill_or_wait_processes() +# except Exception as e: +# logger.error(f"Error during cleanup: {e}", exc_info=True) @router.get('/{instance_id}/state', response_model=VariableStoreStateModel) async def get_instance_state(instance: AttackMate = Depends(get_instance_by_id)): return varstore_to_state_model(instance.varstore) + + +@router.get('/state', response_model=VariableStoreStateModel) +async def get_persistent_instance_state(instance: AttackMate = Depends(get_persistent_instance)): + return varstore_to_state_model(instance.varstore) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 9a9b7d64..7fb63d2c 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -70,8 +70,10 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): try: # base directory exists if not os.path.isdir(ALLOWED_PLAYBOOK_DIR): - logger.error(f"Configuration error: ALLOWED_PLAYBOOK_DIR '{ALLOWED_PLAYBOOK_DIR}' does not exist.") - raise HTTPException(status_code=500, detail='Server configuration error: Playbook directory not found.') + logger.error( + f"Configuration error: ALLOWED_PLAYBOOK_DIR '{ALLOWED_PLAYBOOK_DIR}' does not exist.") + raise HTTPException( + status_code=500, detail='Server configuration error: Playbook directory not found.') requested_path = os.path.normpath(request_body.file_path) # Disallow absolute paths or paths trying to go up directories @@ -112,7 +114,8 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): ) except (ValidationError, ValueError) as e: logger.error(f"Playbook validation error from file '{full_path}': {e}") - raise HTTPException(status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") + raise HTTPException( + status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") except Exception as e: logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") diff --git a/remote_rest/server.py b/remote_rest/server.py deleted file mode 100644 index 3a3e5def..00000000 --- a/remote_rest/server.py +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - # TODO sort out logs for different instances - # TODO return logs to caller - # TODO limit max. concurent instance number - # TODO concurrency for several instances? - # TODO add authentication - # TODO queue requests for instances - # TODO dynamic configuration of attackmate config - # TODO make logging (debug, json etc) configurable at runtime (endpoint or user query paramaters?) - # TODO ALLOWED_PLAYBOOK_DIR -> define in and load from configs - # TODO add swagger examples - # TODO generate OpenAPI schema - # TODO seperate router modules? diff --git a/remote_rest/state.py b/remote_rest/state.py index c0516994..3110a8a1 100644 --- a/remote_rest/state.py +++ b/remote_rest/state.py @@ -32,3 +32,13 @@ def get_instance_by_id( if not instance: raise HTTPException(status_code=404, detail=f"AttackMate instance '{instance_id}' not found.") return instance + + +def get_persistent_instance( + instances: Dict[str, AttackMate] = Depends(get_instances_dict) +) -> AttackMate: + """Dependency to get the default context persistent AttackMate instance, raising 404 if not found.""" + instance = instances.get('default_context') + if not instance: + raise HTTPException(status_code=404, detail='Persistent AttackMate instance not found.') + return instance From e6999926843b5b23c4a37a2ae8db26c66d50fec7 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 13:53:31 +0200 Subject: [PATCH 07/49] give transient instances a uuid and return in response --- remote_rest/routers/playbooks.py | 15 +++++++++------ remote_rest/schemas.py | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 7fb63d2c..245429d8 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -1,5 +1,6 @@ import logging import os +import uuid import yaml from fastapi import APIRouter, Body, HTTPException @@ -32,8 +33,8 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' if not playbook_dict: raise ValueError('Received empty or invalid playbook YAML content.') playbook = Playbook.model_validate(playbook_dict) - - logger.info('Creating transient AttackMate instance...') + instance_id = str(uuid.uuid4()) + logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) return_code = am_instance.main() final_state = varstore_to_state_model(am_instance.varstore) @@ -41,7 +42,8 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' return PlaybookResponseModel( success=(return_code == 0), message='Playbook execution finished.', - final_state=final_state + final_state=final_state, + instance_id=instance_id ) except (yaml.YAMLError, ValidationError, ValueError) as e: logger.error(f"Playbook validation/parsing error: {e}") @@ -101,8 +103,8 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): # Parse the playbook file logger.info(f"Parsing playbook from: {full_path}") playbook = parse_playbook(full_path, logger) - - logger.info('Creating transient AttackMate instance') + instance_id = str(uuid.uuid4()) + logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) return_code = am_instance.main() final_state = varstore_to_state_model(am_instance.varstore) @@ -110,7 +112,8 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): return PlaybookResponseModel( success=(return_code == 0), message=f"Playbook '{request_body.file_path}' execution finished.", - final_state=final_state + final_state=final_state, + instance_id=instance_id ) except (ValidationError, ValueError) as e: logger.error(f"Playbook validation error from file '{full_path}': {e}") diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py index 969c1502..cab85650 100644 --- a/remote_rest/schemas.py +++ b/remote_rest/schemas.py @@ -24,6 +24,7 @@ class PlaybookResponseModel(BaseModel): success: bool message: str final_state: Optional[VariableStoreStateModel] = None + instance_id: Optional[str] = None class InstanceCreationResponse(BaseModel): @@ -32,4 +33,5 @@ class InstanceCreationResponse(BaseModel): class PlaybookFileRequest(BaseModel): - file_path: str = Field(..., description='Path to the playbook file RELATIVE to a predefined server directory.') + file_path: str = Field(..., + description='Path to the playbook file RELATIVE to a predefined server directory.') From f1ff0ff35699932e18bbad29257259305f262e9c Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 14:03:56 +0200 Subject: [PATCH 08/49] add examples to readme --- remote_rest/README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/remote_rest/README.md b/remote_rest/README.md index 8e2a4f4a..9bcd4e5c 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -4,14 +4,56 @@ pip install fastapi uvicorn httpx PyYAML pydantic uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload [] TODO sort out logs for different instances + [] TODO return logs to caller + [] TODO limit max. concurent instance number + [] TODO concurrency for several instances? + [] TODO add authentication + [] TODO queue requests for instances + [] TODO dynamic configuration of attackmate config + [] TODO make logging (debug, json etc) configurable at runtime (endpoint or user query paramaters?) + [] TODO ALLOWED_PLAYBOOK_DIR -> define in and load from configs + [] TODO add swagger examples + [] TODO generate/check OpenAPI schema + [x] TODO seperate router modules? + + + + + +# Execute a playbook by sending its YAML content (uses a temporary instance) +python -m remote_rest.client playbook-yaml examples/playbook.yml + +# Request the server execute a playbook from its allowed directory + Ensure 'playbook.yml' exists in server's ALLOWED_PLAYBOOK_DIR + +python -m remote_rest.client playbook-file safe_playbook.yml + + +# Single Command Execution (on a persistent Instance) + +## Shell Command +```bash +python -m remote_rest.client command shell 'echo "Hello"' +``` + +### Run a command in the background or with metadata + ```bash + python -m remote_rest.client command shell 'echo hello' --metadata tactic=recon --metadata technique=TXXX + ``` + +## Run a Sleep Command (Background): + ```bash + python -m remote_rest.client command sleep --seconds 8 --background + ``` + # (Client returns immediately, server sleeps) From 8d3b804806e2b4a3a9d7be3203aa1389d6cb7707 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 15:50:45 +0200 Subject: [PATCH 09/49] add instance logging with debug parameter --- remote_rest/client.py | 97 +++++++-------------- remote_rest/log_utils.py | 82 +++++++++++++++++ remote_rest/routers/playbooks.py | 145 +++++++++++++++++-------------- remote_rest/schemas.py | 4 + remote_rest/state.py | 2 +- 5 files changed, 197 insertions(+), 133 deletions(-) create mode 100644 remote_rest/log_utils.py diff --git a/remote_rest/client.py b/remote_rest/client.py index 9c3a6680..f01700f2 100644 --- a/remote_rest/client.py +++ b/remote_rest/client.py @@ -2,7 +2,7 @@ import json import logging import sys -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import httpx import yaml @@ -26,49 +26,6 @@ def parse_key_value_pairs(items: List[str] | None) -> Dict[str, str]: return result -# def create_instance(client: httpx.Client, base_url: str) -> Optional[str]: -# """Requests creation of a new persistent instance.""" -# url = f"{base_url}/instances" -# logger.info(f"Requesting new instance creation at {url}...") -# try: -# response = client.post(url) -# response.raise_for_status() -# data = response.json() -# instance_id = data.get('instance_id') -# logger.info(f"Successfully created instance: {instance_id}") -# print(f"Instance Created: {instance_id}") -# return instance_id -# except httpx.RequestError as e: -# logger.error(f"HTTP Request Error creating instance: {e}") -# return None -# except httpx.HTTPStatusError as e: -# logger.error(f"HTTP Status Error creating instance: {e.response.status_code} - {e.response.text}") -# return None -# except Exception as e: -# logger.error(f"Unexpected error creating instance: {e}", exc_info=True) -# return None - - -# def delete_instance_on_server(client: httpx.Client, base_url: str, instance_id: str): -# """Requests deletion of a persistent instance.""" -# url = f"{base_url}/instances/{instance_id}" -# logger.info(f"Requesting deletion of instance {instance_id} at {url}...") -# try: -# response = client.delete(url) -# response.raise_for_status() -# logger.info(f"Successfully requested deletion of instance: {instance_id} (Status: {response.status_code})") -# print(f"Instance {instance_id} deletion requested.") -# except httpx.RequestError as e: -# logger.error(f"HTTP Request Error deleting instance: {e}") -# except httpx.HTTPStatusError as e: -# if e.response.status_code == 404: -# logger.warning(f"Instance {instance_id} not found on server for deletion.") -# else: -# logger.error(f"HTTP Status Error deleting instance: {e.response.status_code} - {e.response.text}") -# except Exception as e: -# logger.error(f"Unexpected error deleting instance: {e}", exc_info=True) - - def get_instance_state_from_server(client: httpx.Client, base_url: str, instance_id: str): """Requests the state of a specific instance.""" url = f"{base_url}/instances/{instance_id}/state" @@ -87,7 +44,9 @@ def get_instance_state_from_server(client: httpx.Client, base_url: str, instance logger.error(f"Unexpected error getting state: {e}", exc_info=True) -def run_playbook_yaml(client: httpx.Client, base_url: str, playbook_file: str): +def run_playbook_yaml( + client: httpx.Client, base_url: str, playbook_file: str, debug: bool = False +): """Sends playbook YAML content to the server.""" url = f"{base_url}/playbooks/execute/yaml" logger.info(f"Attempting to execute playbook from local file: {playbook_file}") @@ -99,13 +58,15 @@ def run_playbook_yaml(client: httpx.Client, base_url: str, playbook_file: str): sys.exit(1) try: + params = {'debug': True} if debug else {} response = client.post(url, content=playbook_yaml_content, headers={ - 'Content-Type': 'application/yaml'}) + 'Content-Type': 'application/yaml'}, params=params) response.raise_for_status() data = response.json() print('\n Playbook YAML Execution Result ') print(f"Success: {data.get('success')}") print(f"Message: {data.get('message')}") + print(f"ID: {data.get('instance_id')}") if data.get('final_state'): print('\n Final Variable Store State ') print(yaml.dump(data['final_state'].get('variables', {}), indent=2)) @@ -122,18 +83,25 @@ def run_playbook_yaml(client: httpx.Client, base_url: str, playbook_file: str): sys.exit(1) -def run_playbook_file(client: httpx.Client, base_url: str, playbook_file_path_on_server: str): +def run_playbook_file( + client: httpx.Client, + base_url: str, + playbook_file_path_on_server: str, + debug: bool = False +): """Requests server to execute a playbook from local path.""" url = f"{base_url}/playbooks/execute/file" logger.info(f"Requesting server execute playbook file: {playbook_file_path_on_server}") payload = {'file_path': playbook_file_path_on_server} try: - response = client.post(url, json=payload) + params = {'debug': True} if debug else {} + response = client.post(url, json=payload, params=params) response.raise_for_status() data = response.json() print('\n Playbook File Execution Result ') print(f"Success: {data.get('success')}") print(f"Message: {data.get('message')}") + print(f"ID: {data.get('instance_id')}") if data.get('final_state'): print('\n Final Variable Store State ') print(yaml.dump(data['final_state'].get('variables', {}), indent=2)) @@ -154,10 +122,6 @@ def run_command(client: httpx.Client, base_url: str, args): """Sends a single command to the server for execution against an instance.""" # TODO this need more special handling for sliver commands? type = args.type - # instance_id = args.instance_id # Get instance ID from args - # if not instance_id: - # logger.error('Instance ID is required for executing commands.') - # sys.exit(1) url = f"{base_url}/command/{type.replace('_', '-')}" logger.info(f"Attempting command '{type}' on instance 'default_context' at {url}") @@ -170,7 +134,10 @@ def run_command(client: httpx.Client, base_url: str, args): if arg_name not in excluded_args and arg_value is not None: pydantic_field_name = arg_name # Type conversions for body (Pydantic/FastAPI handles validation, but ensure basic types) - if arg_name in ['option', 'payload_option', 'metadata', 'prompts', 'output_map', 'header', 'cookie', 'data']: + if arg_name in [ + 'option', 'payload_option', 'metadata', 'prompts', + 'output_map', 'header', 'cookie', 'data' + ]: if isinstance(arg_value, list): body_dict[pydantic_field_name] = parse_key_value_pairs(arg_value) else: @@ -224,24 +191,21 @@ def main(): parser_pb_yaml = subparsers.add_parser( 'playbook-yaml', help='Execute a playbook from a local YAML file content') parser_pb_yaml.add_argument('playbook_file', help='Path to the local playbook YAML file') + parser_pb_yaml.add_argument('--debug', action=argparse.BooleanOptionalAction, + help='Enable server debug logging for this instance') parser_pb_file = subparsers.add_parser( 'playbook-file', help='Request server execute a playbook from its filesystem') parser_pb_file.add_argument('server_playbook_path', help='Path to the playbook file relative to the server\'s allowed directory') + parser_pb_file.add_argument('--debug', action=argparse.BooleanOptionalAction, + help='Enable server debug logging for this instance') - # Instance Management Modes - # parser_inst_create = subparsers.add_parser( - # 'instance-create', help='Create a new persistent AttackMate instance') - # parser_inst_delete = subparsers.add_parser( - # 'instance-delete', help='Delete a persistent AttackMate instance') - # parser_inst_delete.add_argument('instance_id', help='ID of the instance to delete') parser_inst_state = subparsers.add_parser('instance-state', help='Get the state of an instance') parser_inst_state.add_argument('instance_id', help='ID of the instance') # Command Mode parser_command = subparsers.add_parser('command', help='Execute a single command on a specific instance') - # parser_command.add_argument('instance_id', help='ID of the target persistent instance') command_subparsers = parser_command.add_subparsers( dest='type', required=True, help='Specific command type') @@ -254,7 +218,10 @@ def main(): common_args_parser.add_argument('--loop-if-not', help='Regex pattern to loop if no match') common_args_parser.add_argument('--loop-count', type=int, help='Maximum loop iterations') common_args_parser.add_argument( - '--exit-on-error', action=argparse.BooleanOptionalAction, help='Exit if command return code is non-zero') + '--exit-on-error', + action=argparse.BooleanOptionalAction, + help='Exit if command return code is non-zero' + ) common_args_parser.add_argument('--save', help='File path to save command stdout') common_args_parser.add_argument( '--background', action=argparse.BooleanOptionalAction, help='Run command in the background') @@ -313,13 +280,9 @@ def main(): try: # Execute based on mode if args.mode == 'playbook-yaml': - run_playbook_yaml(client, args.base_url, args.playbook_file) + run_playbook_yaml(client, args.base_url, args.playbook_file, args.debug) elif args.mode == 'playbook-file': - run_playbook_file(client, args.base_url, args.server_playbook_path) - # elif args.mode == 'instance-create': - # create_instance(client, args.base_url) - # elif args.mode == 'instance-delete': - # delete_instance_on_server(client, args.base_url, args.instance_id) + run_playbook_file(client, args.base_url, args.server_playbook_path, args.debug) elif args.mode == 'instance-state': get_instance_state_from_server(client, args.base_url, args.instance_id) elif args.mode == 'command': diff --git a/remote_rest/log_utils.py b/remote_rest/log_utils.py new file mode 100644 index 00000000..515fdaa0 --- /dev/null +++ b/remote_rest/log_utils.py @@ -0,0 +1,82 @@ +import logging +import os +import datetime +from contextlib import contextmanager +from typing import List + +# directory for instance logs if running from project root: +LOG_DIR = os.path.join(os.getcwd(), "attackmate_server_logs") +# Or absolute path: +# LOG_DIR = "/var/log/attackmate_instances" # must exists and has write permissions + +# List of logger names to add instance-specific handlers to +TARGET_LOGGER_NAMES = ['playbook', 'output'] # json not needed + +# Create formatter for the instance files +instance_log_formatter = logging.Formatter( + '%(asctime)s %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + + +@contextmanager +def instance_logging(instance_id: str, log_level: int = logging.INFO): + """ + Context manager to temporarily add a file handler for a specific instance + to the target AttackMate loggers. + """ + handlers: List[logging.FileHandler] = [] + instance_output_log_file = None # Initialize + instance_attackmate_log_file = None + + try: + # log directory exists + os.makedirs(LOG_DIR, exist_ok=True) + + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + instance_output_log_file = os.path.join(LOG_DIR, f"{timestamp}_{instance_id}_output.log") + instance_attackmate_log_file = os.path.join(LOG_DIR, f"{timestamp}_{instance_id}_attackmate.log") + + # instance-specific file handler + # 'a' to append within the same request if multiple logs occur + # each request gets a new timestamped file. + output_file_handler = logging.FileHandler(instance_output_log_file, mode='a') + output_file_handler.setFormatter(instance_log_formatter) + output_file_handler.setLevel(log_level) + + attackmate_file_handler = logging.FileHandler(instance_attackmate_log_file, mode='a') + attackmate_file_handler.setFormatter(instance_log_formatter) + attackmate_file_handler.setLevel(log_level) + + # Add the handler to the target loggers + for logger_name in TARGET_LOGGER_NAMES: + logger = logging.getLogger(logger_name) + logger.setLevel(log_level) + if logger_name == 'playbook': + logger.addHandler(attackmate_file_handler) + handlers.append(attackmate_file_handler) # remove later finally + if logger_name == 'output': + logger.addHandler(output_file_handler) + handlers.append(output_file_handler) # remove later in finally + logging.info( + (f"Added instance log handlers for '{instance_id}' to logger '{logger_name}' -> " + f"{instance_output_log_file} and {instance_attackmate_log_file}") + ) + yield # 'with' block executes here + + except Exception as e: + logging.error(f"Error setting up instance logging for '{instance_id}': {e}", exc_info=True) + yield # main code execution if logging fails + + finally: + logging.info(f"Removing instance log handlers for '{instance_id}'...") + for handler in handlers: + try: + for logger_name in TARGET_LOGGER_NAMES: + logger = logging.getLogger(logger_name) + logger.removeHandler(handler) + handler.close() + except Exception as e: + logging.error( + f"Error removing/closing log handler for instance '{instance_id}': {e}", exc_info=True) + logging.info(f"Instance log handlers removed for '{instance_id}'.") diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 245429d8..36e3c73d 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -3,9 +3,10 @@ import uuid import yaml -from fastapi import APIRouter, Body, HTTPException +from fastapi import APIRouter, Body, HTTPException, Query from pydantic import ValidationError +from ..log_utils import instance_logging from attackmate.schemas.playbook import Playbook from remote_rest.schemas import PlaybookFileRequest, PlaybookResponseModel from remote_rest.utils import varstore_to_state_model @@ -16,58 +17,71 @@ router = APIRouter(prefix='/playbooks', tags=['Playbooks']) logger = logging.getLogger(__name__) -ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST and be configured securely +ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST # Playbook Execution @router.post('/execute/yaml', response_model=PlaybookResponseModel) -async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml')): +async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml'), + debug: bool = Query( + False, + description="Enable debug level logging for this request's instance log." +)): """ Executes a playbook provided as YAML content in the request body. Use a transient AttackMate instance. """ logger.info('Received request to execute playbook from YAML content.') - am_instance = None - try: - playbook_dict = yaml.safe_load(playbook_yaml) - if not playbook_dict: - raise ValueError('Received empty or invalid playbook YAML content.') - playbook = Playbook.model_validate(playbook_dict) - instance_id = str(uuid.uuid4()) - logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") - am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) - return_code = am_instance.main() - final_state = varstore_to_state_model(am_instance.varstore) - logger.info(f"Transient playbook execution finished. return code {return_code}") - return PlaybookResponseModel( - success=(return_code == 0), - message='Playbook execution finished.', - final_state=final_state, - instance_id=instance_id - ) - except (yaml.YAMLError, ValidationError, ValueError) as e: - logger.error(f"Playbook validation/parsing error: {e}") - raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") - except Exception as e: - logger.error(f"Unexpected error during playbook execution: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") - finally: - if am_instance: - logger.info('Cleaning up transient playbook instance.') - try: - am_instance.clean_session_stores() - am_instance.pm.kill_or_wait_processes() - except Exception as cleanup_e: - logger.error(f"Error cleaning transient instance: {cleanup_e}", exc_info=True) + instance_id = str(uuid.uuid4()) + log_level = logging.DEBUG if debug else logging.INFO + with instance_logging(instance_id, log_level): + try: + playbook_dict = yaml.safe_load(playbook_yaml) + if not playbook_dict: + raise ValueError('Received empty or invalid playbook YAML content.') + playbook = Playbook.model_validate(playbook_dict) + logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. return code {return_code}") + except (yaml.YAMLError, ValidationError, ValueError) as e: + logger.error(f"Playbook validation/parsing error: {e}") + raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') + try: + am_instance.clean_session_stores() + am_instance.pm.kill_or_wait_processes() + except Exception as cleanup_e: + logger.error(f"Error cleaning transient instance: {cleanup_e}", exc_info=True) + + return PlaybookResponseModel( + success=(return_code == 0), + message='Playbook execution finished.', + final_state=final_state, + instance_id=instance_id + ) @router.post('/execute/file', response_model=PlaybookResponseModel) -async def execute_playbook_from_file(request_body: PlaybookFileRequest): +async def execute_playbook_from_file(request_body: PlaybookFileRequest, + debug: bool = Query( + False, + description=( + "Enable debug level logging for this request's instance log." + ) + ) + ): """ Executes a playbook located at a specific path *on the server*. Uses a transient AttackMate instance. """ - # TODO ensure this only executes playbooks in certain locations, not arbitrary code -> read up on path traversal + # TODO ensure this only executes playbooks in certain locations -> read up on path traversal logger.info(f"Received request to execute playbook from file: {request_body.file_path}") try: # base directory exists @@ -87,7 +101,7 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): if not os.path.abspath(full_path).startswith(os.path.abspath(ALLOWED_PLAYBOOK_DIR)): raise ValueError('Invalid playbook path specified (path traversal attempt ).') - # Check if the file exists + # Check if the file exists if not os.path.isfile(full_path): raise FileNotFoundError(f"Playbook file not found atpath: {full_path}") @@ -98,35 +112,36 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest): logger.error(f"Error processing playbook path: {e}", exc_info=True) raise HTTPException(status_code=500, detail='Server error processing file path.') - am_instance = None - try: - # Parse the playbook file - logger.info(f"Parsing playbook from: {full_path}") - playbook = parse_playbook(full_path, logger) - instance_id = str(uuid.uuid4()) - logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") - am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) - return_code = am_instance.main() - final_state = varstore_to_state_model(am_instance.varstore) - logger.info(f"Transient playbook execution finished. RC: {return_code}") - return PlaybookResponseModel( - success=(return_code == 0), - message=f"Playbook '{request_body.file_path}' execution finished.", - final_state=final_state, - instance_id=instance_id - ) - except (ValidationError, ValueError) as e: - logger.error(f"Playbook validation error from file '{full_path}': {e}") - raise HTTPException( - status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") - except Exception as e: - logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") - finally: - if am_instance: - logger.info('Cleaning up transient playbook instance.') + instance_id = str(uuid.uuid4()) + log_level = logging.DEBUG if debug else logging.INFO + with instance_logging(instance_id, log_level): + try: + logger.info(f"Parsing playbook from: {full_path}") + playbook = parse_playbook(full_path, logger) + logger.info(f"Creating transient AttackMate instance, ID: {instance_id}") + am_instance = AttackMate(playbook=playbook, config=attackmate_config, varstore=None) + return_code = am_instance.main() + final_state = varstore_to_state_model(am_instance.varstore) + logger.info(f"Transient playbook execution finished. RC: {return_code}") + except (ValidationError, ValueError) as e: + logger.error(f"Playbook validation error from file '{full_path}': {e}") + raise HTTPException( + status_code=400, detail=f"Invalid playbook content in file '{request_body.file_path}': {e}") + except Exception as e: + logger.error(f"Unexpected error during playbook file execution: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Server error during playbook execution: {e}") + finally: + if am_instance: + logger.info('Cleaning up transient playbook instance.') try: am_instance.clean_session_stores() am_instance.pm.kill_or_wait_processes() except Exception as e: logger.error(f"Cleanup error: {e}") + + return PlaybookResponseModel( + success=(return_code == 0), + message=f"Playbook '{request_body.file_path}' execution finished.", + final_state=final_state, + instance_id=instance_id + ) diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py index cab85650..3b509527 100644 --- a/remote_rest/schemas.py +++ b/remote_rest/schemas.py @@ -35,3 +35,7 @@ class InstanceCreationResponse(BaseModel): class PlaybookFileRequest(BaseModel): file_path: str = Field(..., description='Path to the playbook file RELATIVE to a predefined server directory.') + debug: bool = Field( + False, + description='If true, the playbook will be executed in debug mode. ' + ) diff --git a/remote_rest/state.py b/remote_rest/state.py index 3110a8a1..1473b81d 100644 --- a/remote_rest/state.py +++ b/remote_rest/state.py @@ -12,7 +12,7 @@ def get_instances_dict() -> Dict[str, AttackMate]: """Dependency to get the shared INSTANCES dictionary.""" - # Simply returns the global dict reference + # returns the global dict reference return INSTANCES From f796692073eb14ebb11ed9eb430c8694aaeec99a Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 5 May 2025 16:17:42 +0200 Subject: [PATCH 10/49] return logfile content on playbook execution --- remote_rest/log_utils.py | 4 ++-- remote_rest/routers/playbooks.py | 32 ++++++++++++++++++++++++++++---- remote_rest/schemas.py | 2 ++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/remote_rest/log_utils.py b/remote_rest/log_utils.py index 515fdaa0..1e856cc2 100644 --- a/remote_rest/log_utils.py +++ b/remote_rest/log_utils.py @@ -2,7 +2,7 @@ import os import datetime from contextlib import contextmanager -from typing import List +from typing import Generator, List, Optional # directory for instance logs if running from project root: LOG_DIR = os.path.join(os.getcwd(), "attackmate_server_logs") @@ -62,7 +62,7 @@ def instance_logging(instance_id: str, log_level: int = logging.INFO): (f"Added instance log handlers for '{instance_id}' to logger '{logger_name}' -> " f"{instance_output_log_file} and {instance_attackmate_log_file}") ) - yield # 'with' block executes here + yield [instance_attackmate_log_file, instance_output_log_file] # 'with' block executes here except Exception as e: logging.error(f"Error setting up instance logging for '{instance_id}': {e}", exc_info=True) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 36e3c73d..3338b6ec 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -1,5 +1,6 @@ import logging import os +from typing import Optional import uuid import yaml @@ -20,7 +21,20 @@ ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST +# helper tp read logfil +def read_log_file(log_path: Optional[str]) -> Optional[str]: + if not log_path or not os.path.exists(log_path): + return None + try: + with open(log_path, 'r') as f: + return f.read() + except Exception as e: + logger.error(f"Failed to read log file '{log_path}': {e}") + return f"Error reading log file: {e}" + # Playbook Execution + + @router.post('/execute/yaml', response_model=PlaybookResponseModel) async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml'), debug: bool = Query( @@ -34,7 +48,8 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' logger.info('Received request to execute playbook from YAML content.') instance_id = str(uuid.uuid4()) log_level = logging.DEBUG if debug else logging.INFO - with instance_logging(instance_id, log_level): + with instance_logging(instance_id, log_level) as log_files: + attackmate_log_path, output_log_path = log_files try: playbook_dict = yaml.safe_load(playbook_yaml) if not playbook_dict: @@ -45,6 +60,8 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' return_code = am_instance.main() final_state = varstore_to_state_model(am_instance.varstore) logger.info(f"Transient playbook execution finished. return code {return_code}") + attackmate_log = read_log_file(attackmate_log_path) + output_log = read_log_file(output_log_path) except (yaml.YAMLError, ValidationError, ValueError) as e: logger.error(f"Playbook validation/parsing error: {e}") raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") @@ -64,7 +81,9 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' success=(return_code == 0), message='Playbook execution finished.', final_state=final_state, - instance_id=instance_id + instance_id=instance_id, + attackmate_log=attackmate_log, + output_log=output_log ) @@ -114,7 +133,8 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, instance_id = str(uuid.uuid4()) log_level = logging.DEBUG if debug else logging.INFO - with instance_logging(instance_id, log_level): + with instance_logging(instance_id, log_level) as log_files: + attackmate_log_path, output_log_path = log_files try: logger.info(f"Parsing playbook from: {full_path}") playbook = parse_playbook(full_path, logger) @@ -123,6 +143,8 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, return_code = am_instance.main() final_state = varstore_to_state_model(am_instance.varstore) logger.info(f"Transient playbook execution finished. RC: {return_code}") + attackmate_log = read_log_file(attackmate_log_path) + output_log = read_log_file(output_log_path) except (ValidationError, ValueError) as e: logger.error(f"Playbook validation error from file '{full_path}': {e}") raise HTTPException( @@ -143,5 +165,7 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, success=(return_code == 0), message=f"Playbook '{request_body.file_path}' execution finished.", final_state=final_state, - instance_id=instance_id + instance_id=instance_id, + attackmate_log=attackmate_log, + output_log=output_log ) diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py index 3b509527..8fffba32 100644 --- a/remote_rest/schemas.py +++ b/remote_rest/schemas.py @@ -25,6 +25,8 @@ class PlaybookResponseModel(BaseModel): message: str final_state: Optional[VariableStoreStateModel] = None instance_id: Optional[str] = None + attackmate_log: Optional[str] = Field(None, description='Content of the attackmate.log for this run.') + output_log: Optional[str] = Field(None, description='Content of the output.log for this run.') class InstanceCreationResponse(BaseModel): From 678f13126f14bb808593bdcc21ad4a569d19db5c Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 6 May 2025 15:33:51 +0200 Subject: [PATCH 11/49] create hash utility --- remote_rest/create_hashes.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 remote_rest/create_hashes.py diff --git a/remote_rest/create_hashes.py b/remote_rest/create_hashes.py new file mode 100644 index 00000000..433af170 --- /dev/null +++ b/remote_rest/create_hashes.py @@ -0,0 +1,19 @@ +import os + +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + + +users = { + 'user': 'user', + 'admin': 'admin', +} + +env_content = '' +print('\nCopy the following lines into your .env file:\n') +for username, plain_password in users.items(): + hashed_password = pwd_context.hash(plain_password) + env_line = f"USER_{username.upper()}_HASH=\"{hashed_password}\"" + print(env_line) + env_content += env_line + '\n' From 8ea042cbb99e8e38e958d35bf903e3af08da7b46 Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 6 May 2025 15:34:44 +0200 Subject: [PATCH 12/49] new response schemas --- remote_rest/schemas.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py index 8fffba32..5dd18c6e 100644 --- a/remote_rest/schemas.py +++ b/remote_rest/schemas.py @@ -18,6 +18,7 @@ class ExecutionResponseModel(BaseModel): result: CommandResultModel state: VariableStoreStateModel instance_id: Optional[str] = None + current_token: Optional[str] = Field(None, description='Renewed auth token for subsequent requests.') class PlaybookResponseModel(BaseModel): @@ -27,6 +28,7 @@ class PlaybookResponseModel(BaseModel): instance_id: Optional[str] = None attackmate_log: Optional[str] = Field(None, description='Content of the attackmate.log for this run.') output_log: Optional[str] = Field(None, description='Content of the output.log for this run.') + current_token: Optional[str] = Field(None, description='Renewed auth token for subsequent requests.') class InstanceCreationResponse(BaseModel): @@ -41,3 +43,8 @@ class PlaybookFileRequest(BaseModel): False, description='If true, the playbook will be executed in debug mode. ' ) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = 'bearer' From 41109ecc83ad2485176887ee7d17d3c061f8a47f Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 6 May 2025 15:45:53 +0200 Subject: [PATCH 13/49] authentication with Token --- remote_rest/README.md | 1 + remote_rest/auth_utils.py | 103 +++++++++++++++++++++++++++++++ remote_rest/client.py | 95 ++++++++++++++++++++++++++-- remote_rest/log_utils.py | 2 +- remote_rest/main.py | 61 ++++++++++++++---- remote_rest/routers/commands.py | 66 +++++++++++++++----- remote_rest/routers/instances.py | 55 +++-------------- remote_rest/routers/playbooks.py | 27 +++++--- remote_rest/state.py | 1 - remote_rest/utils.py | 5 +- 10 files changed, 322 insertions(+), 94 deletions(-) create mode 100644 remote_rest/auth_utils.py diff --git a/remote_rest/README.md b/remote_rest/README.md index 9bcd4e5c..9063f5d6 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -1,4 +1,5 @@ pip install fastapi uvicorn httpx PyYAML pydantic +bcrypt==3.2.2 !! uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/remote_rest/auth_utils.py b/remote_rest/auth_utils.py new file mode 100644 index 00000000..893cbb48 --- /dev/null +++ b/remote_rest/auth_utils.py @@ -0,0 +1,103 @@ +import logging +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from dotenv import load_dotenv +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader +from passlib.context import CryptContext + +load_dotenv() + + +TOKEN_EXPIRE_MINUTES = int(os.getenv('TOKEN_EXPIRE_MINUTES', 30)) +API_KEY_HEADER_NAME = 'X-Auth-Token' +api_key_header_scheme = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=True) +pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + +# In-Memory token Store +# token looks like this {"username": str, "expires": datetime} +# state is lost on server restart. +# Not inherently thread-safe for multi-worker setups without locks ? +ACTIVE_TOKENS: Dict[str, Dict[str, Any]] = {} + +logger = logging.getLogger(__name__) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_user_hash(username: str) -> Optional[str]: + """Fetches the hashed password from environment variables.""" + env_var_name = f"USER_{username.upper()}_HASH" + return os.getenv(env_var_name) + + +def create_access_token(username: str) -> str: + """Creates a new token, stores it, and returns the token string.""" + token = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES) + # TODO locking needed for multi-threaded access, smth like with token_lock ? + ACTIVE_TOKENS[token] = {'username': username, 'expires': expires} + logger.info(f"Created new token for user '{username}' expiring at {expires}") + return token + + +def renew_token_expiry(token: str) -> bool: + """Updates the expiry time for an existing token. Returns True if successful.""" + token_data = ACTIVE_TOKENS.get(token) + if token_data: + token_data['expires'] = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES) + logger.debug(f"Renewed token expiry for user '{token_data['username']}'") + return True + return False + + +def cleanup_expired_tokens(): + """Removes expired tokens from the store""" + now = datetime.now(timezone.utc) + expired_tokens = [token for token, data in ACTIVE_TOKENS.items() if data['expires'] < now] + for token in expired_tokens: + username = ACTIVE_TOKENS.get(token, {}).get('username', 'unknown') + del ACTIVE_TOKENS[token] + logger.info(f"Removed expired token for user '{username}'.") + + +# Authentication Dependency -> this gets passed to the routes +async def get_current_user(token: str = Depends(api_key_header_scheme)) -> str: + """ + validate token and return the username + renews the token's expiration on successful validation + cleanup of expired tokens. + """ + + cleanup_expired_tokens() + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid authentication credentials', + headers={'WWW-Authenticate': 'Bearer'}, + ) + + token_data = ACTIVE_TOKENS.get(token) + if not token_data: + logger.warning(f"Token not found: {token[:5]}...") + raise credentials_exception + + username: str = token_data['username'] + expires: datetime = token_data['expires'] + + if expires < datetime.now(timezone.utc): + logger.warning(f"Token expired for user '{username}'") + # Remove the expired token + if token in ACTIVE_TOKENS: + del ACTIVE_TOKENS[token] + raise credentials_exception + + renew_token_expiry(token) + + logger.debug(f"Token validated successfully for user: {username}") + return username diff --git a/remote_rest/client.py b/remote_rest/client.py index f01700f2..7a9ec6b9 100644 --- a/remote_rest/client.py +++ b/remote_rest/client.py @@ -1,8 +1,9 @@ import argparse import json import logging +import os import sys -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import httpx import yaml @@ -11,6 +12,50 @@ logger = logging.getLogger(__name__) +# Authentication +CURRENT_TOKEN: Optional[str] = None +# token can then be saved in env var since it does not persit in client memory +TOKEN_ENV_VAR = 'ATTACKMATE_API_TOKEN' + + +def load_token(): + """Loads token from global or env""" + global CURRENT_TOKEN + if CURRENT_TOKEN: + return CURRENT_TOKEN + CURRENT_TOKEN = os.getenv(TOKEN_ENV_VAR) + if CURRENT_TOKEN: + logger.info('Token loaded') + return CURRENT_TOKEN + + +def save_token(token: Optional[str]): + """Saves token to global var and env""" + global CURRENT_TOKEN + CURRENT_TOKEN = token + if token: + # This is pretty hacky, client mainly for testing purposes + logger.info('updating env var') + logger.info(f"run in your shell: export ATTACKMATE_API_TOKEN={token}") + else: + os.environ.pop(TOKEN_ENV_VAR, None) + + +def get_auth_headers() -> Dict[str, str]: + token = load_token() + if token: + return {'X-Auth-Token': token} + return {} + + +def update_token_from_response(data: Dict[str, Any]): + """Updates the stored token if present in the response data.""" + new_token = data.get('current_token') + if new_token: + logger.info('Received renewed token in response') + save_token(new_token) + + # Helper Functions def parse_key_value_pairs(items: List[str] | None) -> Dict[str, str]: """Helper to parse 'key=value' strings from a list into a dict.""" @@ -26,14 +71,43 @@ def parse_key_value_pairs(items: List[str] | None) -> Dict[str, str]: return result +# Login +def login(client: httpx.Client, base_url: str, username: str, password: str): + """Logs in and saves the token.""" + url = f"{base_url}/login" + logger.info(f"Attempting login for user '{username}' at {url}...") + try: + # standard form encoding for OAuth2PasswordRequestForm -> expected bei Fastapi + response = client.post(url, data={'username': username, 'password': password}) + response.raise_for_status() + data = response.json() + token = data.get('access_token') + if token: + save_token(token) # workaround, export to env var in shell + print(f"Login successful. Token received: {token[:5]}...") + else: + logger.error(' No access token received in response.') + sys.exit(1) + except httpx.RequestError as e: + logger.error(f"HTTP Request Error during login: {e}") + sys.exit(1) + except httpx.HTTPStatusError as e: + logger.error(f"Login failed: {e.response.status_code}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error during login: {e}", exc_info=True) + sys.exit(1) + + def get_instance_state_from_server(client: httpx.Client, base_url: str, instance_id: str): """Requests the state of a specific instance.""" url = f"{base_url}/instances/{instance_id}/state" logger.info(f"Requesting state for instance {instance_id} at {url}...") try: - response = client.get(url) + response = client.get(url, headers=get_auth_headers()) response.raise_for_status() data = response.json() + update_token_from_response(data) print(f"\n State for Instance {instance_id} ") print(yaml.dump(data.get('variables', {}), indent=2)) except httpx.RequestError as e: @@ -56,13 +130,13 @@ def run_playbook_yaml( except Exception as e: logger.error(f"Error reading file '{playbook_file}': {e}") sys.exit(1) - try: params = {'debug': True} if debug else {} - response = client.post(url, content=playbook_yaml_content, headers={ + response = client.post(url, content=playbook_yaml_content, headers={**get_auth_headers(), 'Content-Type': 'application/yaml'}, params=params) response.raise_for_status() data = response.json() + update_token_from_response(data) print('\n Playbook YAML Execution Result ') print(f"Success: {data.get('success')}") print(f"Message: {data.get('message')}") @@ -95,9 +169,10 @@ def run_playbook_file( payload = {'file_path': playbook_file_path_on_server} try: params = {'debug': True} if debug else {} - response = client.post(url, json=payload, params=params) + response = client.post(url, json=payload, params=params, headers=get_auth_headers()) response.raise_for_status() data = response.json() + update_token_from_response(data) print('\n Playbook File Execution Result ') print(f"Success: {data.get('success')}") print(f"Message: {data.get('message')}") @@ -146,9 +221,10 @@ def run_command(client: httpx.Client, base_url: str, args): try: logger.debug(f"Sending POST to {url}") logger.debug(f"Request Body: {json.dumps(body_dict, indent=2)}") - response = client.post(url, json=body_dict) + response = client.post(url, json=body_dict, headers=get_auth_headers()) response.raise_for_status() data = response.json() + update_token_from_response(data) logger.info(f"Received response from /{type} endpoint.") logger.debug(f"Response data: {data}") @@ -187,6 +263,11 @@ def main(): help='Base URL of the AttackMate API server') subparsers = parser.add_subparsers(dest='mode', required=True, help='Operation mode') + # Login Mode + parser_login = subparsers.add_parser('login', help='Authenticate and get a token') + parser_login.add_argument('username', help='API username') + parser_login.add_argument('password', help='API password') + # Playbook Modes parser_pb_yaml = subparsers.add_parser( 'playbook-yaml', help='Execute a playbook from a local YAML file content') @@ -279,6 +360,8 @@ def main(): with httpx.Client(base_url=args.base_url, timeout=60.0) as client: try: # Execute based on mode + if args.mode == 'login': + login(client, args.base_url, args.username, args.password) if args.mode == 'playbook-yaml': run_playbook_yaml(client, args.base_url, args.playbook_file, args.debug) elif args.mode == 'playbook-file': diff --git a/remote_rest/log_utils.py b/remote_rest/log_utils.py index 1e856cc2..36bcfe3a 100644 --- a/remote_rest/log_utils.py +++ b/remote_rest/log_utils.py @@ -1,6 +1,6 @@ +import datetime import logging import os -import datetime from contextlib import contextmanager from typing import Generator, List, Optional diff --git a/remote_rest/main.py b/remote_rest/main.py index 84caba84..385c64ed 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -3,24 +3,28 @@ from typing import AsyncGenerator import uvicorn -from fastapi import FastAPI, Request +from attackmate.execexception import ExecException +from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.responses import JSONResponse - -import remote_rest.state as state +from fastapi.security import OAuth2PasswordRequestForm from src.attackmate.attackmate import AttackMate -from attackmate.execexception import ExecException -from remote_rest.routers import commands, instances, playbooks from src.attackmate.logging_setup import (initialize_json_logger, initialize_logger, initialize_output_logger) from src.attackmate.playbook_parser import parse_config +import remote_rest.state as state +from remote_rest.routers import commands, instances, playbooks + +from .auth_utils import create_access_token, get_user_hash, verify_password +from .schemas import TokenResponse + # Logging initialize_logger(debug=True, append_logs=False) initialize_output_logger(debug=True, append_logs=False) initialize_json_logger(json=True, append_logs=False) logger = logging.getLogger('attackmate_api') # specific logger for the API -# TODO make this configurable via request, even also per attackmate instance? +# TODO make this configurable via request logger.setLevel(logging.DEBUG) @@ -72,11 +76,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: version='1.0.0', lifespan=lifespan) -# Include Routers -app.include_router(instances.router, prefix='/instances') -app.include_router(playbooks.router) -app.include_router(commands.router) - # Exception Handling @app.exception_handler(ExecException) @@ -107,5 +106,45 @@ async def generic_exception_handler(request: Request, exc: Exception): # Re-raise other exceptions for specific hanfling? raise exc + +# Login endpoint +@app.post('/login', response_model=TokenResponse, tags=['Auth']) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + """Authenticates user and returns an access token.""" + logger.info(f"Login attempt for user: {form_data.username}") + hashed_password = get_user_hash(form_data.username) + if not hashed_password: + logger.warning(f"Login failed: User '{form_data.username}' not found.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Incorrect username or password', + headers={'WWW-Authenticate': 'Bearer'}, + ) + + if not verify_password(form_data.password, hashed_password): + logger.warning(f"Login failed: Invalid password for user '{form_data.username}'.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Incorrect username or password', + headers={'WWW-Authenticate': 'Bearer'}, + ) + + # vlid password -> create token + access_token = create_access_token(username=form_data.username) + logger.info(f"Login successful for user '{form_data.username}'. Token created.") + # Return token + return TokenResponse(access_token=access_token, token_type='bearer') + +# Include Routers +app.include_router(instances.router, prefix='/instances') +app.include_router(playbooks.router) +app.include_router(commands.router) + + +# Root Endpoint +@app.get('/', include_in_schema=False) +async def root(): + return {'message': 'AttackMate API is running. Use /login to authenticate. See /docs.'} + if __name__ == '__main__': uvicorn.run('remote_rest.main:app', host='0.0.0.0', port=8000, reload=True, log_config=None,) diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 923b2544..9de762d2 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -1,11 +1,9 @@ import logging - -from fastapi import APIRouter, Depends, HTTPException +from typing import Optional from attackmate.attackmate import AttackMate from attackmate.schemas.base import BaseCommand -from remote_rest.schemas import CommandResultModel, ExecutionResponseModel -from remote_rest.utils import varstore_to_state_model +from fastapi import APIRouter, Depends, Header, HTTPException from src.attackmate.execexception import ExecException from src.attackmate.result import Result as AttackMateResult from src.attackmate.schemas.debug import DebugCommand @@ -14,6 +12,10 @@ from src.attackmate.schemas.sleep import SleepCommand from src.attackmate.schemas.tempfile import TempfileCommand +from remote_rest.auth_utils import API_KEY_HEADER_NAME, get_current_user +from remote_rest.schemas import CommandResultModel, ExecutionResponseModel +from remote_rest.utils import varstore_to_state_model + from ..state import get_persistent_instance # ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE @@ -43,7 +45,9 @@ async def run_command_on_instance(instance: AttackMate, command_data: BaseComman @router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_shell_command( command: ShellCommand, - instance: AttackMate = Depends(get_persistent_instance) + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """Executes a shell command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands @@ -56,26 +60,40 @@ async def execute_shell_command( returncode=attackmate_result.returncode ) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') + return ExecutionResponseModel( + result=result_model, + state=state_model, + instance_id='default-context', + current_token=x_auth_token + ) @router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_sleep_command( command: SleepCommand, - instance: AttackMate = Depends(get_persistent_instance) + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """Executes a sleep command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) result_model = CommandResultModel( success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') + return ExecutionResponseModel( + result=result_model, + state=state_model, + instance_id='default-context', + current_token=x_auth_token + ) @router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_debug_command( command: DebugCommand, - instance: AttackMate = Depends(get_persistent_instance) + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """Executes a debug command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) @@ -83,32 +101,50 @@ async def execute_debug_command( result_model = CommandResultModel( success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') + return ExecutionResponseModel( + result=result_model, + state=state_model, + instance_id='default-context', + current_token=x_auth_token + ) @router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_setvar_command( command: SetVarCommand, - instance: AttackMate = Depends(get_persistent_instance) + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """Executes a setvar command on the specified AttackMate instance.""" attackmate_result = await run_command_on_instance(instance, command) result_model = CommandResultModel( success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') + return ExecutionResponseModel( + result=result_model, + state=state_model, + instance_id='default-context', + current_token=x_auth_token + ) @router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) async def execute_mktemp_command( command: TempfileCommand, - instance: AttackMate = Depends(get_persistent_instance) + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """Executes an mktemp command (create temp file/dir) on the specified instance.""" attackmate_result = await run_command_on_instance(instance, command) result_model = CommandResultModel( success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel(result=result_model, state=state_model, instance_id='default-context') - + return ExecutionResponseModel( + result=result_model, + state=state_model, + instance_id='default-context', + current_token=x_auth_token + ) # Add other command endpoints here diff --git a/remote_rest/routers/instances.py b/remote_rest/routers/instances.py index 3fcf2985..c8b4c7ec 100644 --- a/remote_rest/routers/instances.py +++ b/remote_rest/routers/instances.py @@ -1,64 +1,23 @@ import logging -import uuid -from typing import Dict -from fastapi import APIRouter, Depends, HTTPException, Path +from fastapi import APIRouter, Depends +from src.attackmate.attackmate import AttackMate -from remote_rest.schemas import (InstanceCreationResponse, - VariableStoreStateModel) +from remote_rest.auth_utils import get_current_user +from remote_rest.schemas import VariableStoreStateModel from remote_rest.utils import varstore_to_state_model -from src.attackmate.attackmate import AttackMate -from src.attackmate.schemas.config import Config -from ..state import (get_attackmate_config, get_instance_by_id, - get_instances_dict, get_persistent_instance) +from ..state import get_instance_by_id, get_persistent_instance router = APIRouter(tags=['Instances']) logger = logging.getLogger(__name__) -# THIS CAN POTENTIALLY BE USED TO GNERATE NEW INSTANCES WITH IDS -# @router.post('', response_model=InstanceCreationResponse) -# async def create_new_instance( -# instances: Dict[str, AttackMate] = Depends(get_instances_dict), -# config: Config = Depends(get_attackmate_config) -# ): -# """Creates a new persistent AttackMate instance and returns its ID.""" -# instance_id = str(uuid.uuid4()) -# logger.info(f"Creating instance ID: {instance_id}") -# try: -# instance = AttackMate(playbook=None, config=config, varstore=None) -# instances[instance_id] = instance # Modify injected dict -# logger.info(f"Instance {instance_id} created.") -# return InstanceCreationResponse(instance_id=instance_id, message='Instance created.') -# except Exception as e: -# logger.error(f"Failed to create instance {instance_id}: {e}", exc_info=True) -# if instance_id in instances: -# del instances[instance_id] -# raise HTTPException(status_code=500, detail='Failed to create instance.') - - -# @router.delete('/{instance_id}', status_code=204) -# async def delete_instance_route( -# instance_id: str = Path(...), -# instances: Dict[str, AttackMate] = Depends(get_instances_dict) -# ): -# instance = instances.pop(instance_id, None) -# if not instance: -# raise HTTPException(status_code=404, detail=f"Instance '{instance_id}' not found.") -# logger.info(f"Deleting instance {instance_id}...") -# try: -# instance.clean_session_stores() -# instance.pm.kill_or_wait_processes() -# except Exception as e: -# logger.error(f"Error during cleanup: {e}", exc_info=True) - - @router.get('/{instance_id}/state', response_model=VariableStoreStateModel) -async def get_instance_state(instance: AttackMate = Depends(get_instance_by_id)): +async def get_instance_state(instance: AttackMate = Depends(get_instance_by_id), current_user: str = Depends(get_current_user)): return varstore_to_state_model(instance.varstore) @router.get('/state', response_model=VariableStoreStateModel) -async def get_persistent_instance_state(instance: AttackMate = Depends(get_persistent_instance)): +async def get_persistent_instance_state(instance: AttackMate = Depends(get_persistent_instance), current_user: str = Depends(get_current_user)): return varstore_to_state_model(instance.varstore) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 3338b6ec..4f31bf08 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -1,19 +1,20 @@ import logging import os -from typing import Optional import uuid +from typing import Optional import yaml -from fastapi import APIRouter, Body, HTTPException, Query +from attackmate.schemas.playbook import Playbook +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query from pydantic import ValidationError +from src.attackmate.attackmate import AttackMate +from src.attackmate.playbook_parser import parse_playbook -from ..log_utils import instance_logging -from attackmate.schemas.playbook import Playbook +from remote_rest.auth_utils import API_KEY_HEADER_NAME, get_current_user from remote_rest.schemas import PlaybookFileRequest, PlaybookResponseModel from remote_rest.utils import varstore_to_state_model -from src.attackmate.attackmate import AttackMate -from src.attackmate.playbook_parser import parse_playbook +from ..log_utils import instance_logging from ..state import attackmate_config router = APIRouter(prefix='/playbooks', tags=['Playbooks']) @@ -40,7 +41,9 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' debug: bool = Query( False, description="Enable debug level logging for this request's instance log." -)): +), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME)): """ Executes a playbook provided as YAML content in the request body. Use a transient AttackMate instance. @@ -83,7 +86,8 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' final_state=final_state, instance_id=instance_id, attackmate_log=attackmate_log, - output_log=output_log + output_log=output_log, + current_token=x_auth_token ) @@ -94,7 +98,9 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, description=( "Enable debug level logging for this request's instance log." ) - ) + ), + current_user: str = Depends(get_current_user), + x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): """ Executes a playbook located at a specific path *on the server*. @@ -167,5 +173,6 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, final_state=final_state, instance_id=instance_id, attackmate_log=attackmate_log, - output_log=output_log + output_log=output_log, + current_token=x_auth_token ) diff --git a/remote_rest/state.py b/remote_rest/state.py index 1473b81d..a8f0bbe1 100644 --- a/remote_rest/state.py +++ b/remote_rest/state.py @@ -1,7 +1,6 @@ from typing import Dict, Optional from fastapi import Depends, HTTPException, Path - from src.attackmate.attackmate import AttackMate from src.attackmate.schemas.config import Config diff --git a/remote_rest/utils.py b/remote_rest/utils.py index 5be58b04..7f727c19 100644 --- a/remote_rest/utils.py +++ b/remote_rest/utils.py @@ -1,9 +1,10 @@ import logging from typing import Any, Dict -from remote_rest.schemas import VariableStoreStateModel from src.attackmate.variablestore import VariableStore +from remote_rest.schemas import VariableStoreStateModel + logger = logging.getLogger(__name__) @@ -11,7 +12,7 @@ def varstore_to_state_model(varstore: VariableStore) -> VariableStoreStateModel: """Converts AttackMate VariableStore to Pydantic VariableStoreStateModel.""" if not isinstance(varstore, VariableStore): logger.error(f"Invalid type passed to varstore_to_state_model: {type(varstore)}") - return VariableStoreStateModel(variables={'error': 'Internal state error'}) # Prevent crashes + return VariableStoreStateModel(variables={'error': 'Internal state error'}) combined_vars: Dict[str, Any] = {} combined_vars.update(varstore.variables) combined_vars.update(varstore.lists) From 19f0745475ab7558f72f7bb7ef83cf414c5e4f2c Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 6 May 2025 15:49:45 +0200 Subject: [PATCH 14/49] fastapi dependency! --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f3311232..72349a56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,8 @@ dependencies = [ "httpx[http2]", "vncdotool", "pytest-mock", - "fastapi" + "fastapi", + "playwright" ] dynamic = ["version"] From 5fab49440c82e8e14f04f24377b7d9e6adaa6764 Mon Sep 17 00:00:00 2001 From: kali Date: Wed, 7 May 2025 13:04:34 +0200 Subject: [PATCH 15/49] self signed ssl keys --- remote_rest/README.md | 19 ++++++++++++++++++- remote_rest/client.py | 21 +++++++++++++++++++-- remote_rest/main.py | 22 ++++++++++++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/remote_rest/README.md b/remote_rest/README.md index 9063f5d6..85310eb6 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -1,5 +1,5 @@ pip install fastapi uvicorn httpx PyYAML pydantic -bcrypt==3.2.2 !! +bcrypt==3.2.2 !! otherwise passlib complains uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload @@ -58,3 +58,20 @@ python -m remote_rest.client command shell 'echo "Hello"' python -m remote_rest.client command sleep --seconds 8 --background ``` # (Client returns immediately, server sleeps) + + +# Certificate generation +preliminary, automate later? +with open ssl + ```bash + openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes + ``` + +Common Name: localhost (or ip adress the server will be) + + +running client: + +```bash +python -m client --cacert login user user +``` diff --git a/remote_rest/client.py b/remote_rest/client.py index 7a9ec6b9..c3091041 100644 --- a/remote_rest/client.py +++ b/remote_rest/client.py @@ -259,8 +259,12 @@ def run_command(client: httpx.Client, base_url: str, args): # Main Execution Logic def main(): parser = argparse.ArgumentParser(description='AttackMate REST API Client') - parser.add_argument('--base-url', default='http://localhost:8000', + parser.add_argument('--base-url', default='https://localhost:8443', help='Base URL of the AttackMate API server') + parser.add_argument( + '--cacert', + help='Path to the server\'s self-signed certificate file (cert.pem) for verification.' + ) subparsers = parser.add_subparsers(dest='mode', required=True, help='Operation mode') # Login Mode @@ -355,9 +359,16 @@ def main(): # ADD SUBPARSERS FOR ALL OTHER COMMAND TYPES HERE args = parser.parse_args() + if args.cacert: + cert_path = os.path.abspath(args.cacert) # Ensure absolute path + if os.path.exists(cert_path): + logger.info(f"Configured httpx to verify using CA cert: {cert_path}") + else: + logger.error(f"CA certificate file not found at specified path: {cert_path}") + sys.exit(1) # Create HTTP Client - with httpx.Client(base_url=args.base_url, timeout=60.0) as client: + with httpx.Client(base_url=args.base_url, timeout=60.0, verify=cert_path) as client: try: # Execute based on mode if args.mode == 'login': @@ -375,6 +386,12 @@ def main(): logger.error('Internal error: Command mode selected but no command type specified.') parser.print_help() sys.exit(1) + except httpx.ConnectError as e: + logger.error( + f"Connection Error: Could not connect to {args.base_url}. " + f"Is the server running with HTTPS? Did you provide cert? Details: {e}" + ) + sys.exit(1) except Exception as main_err: logger.error(f"Client execution failed: {main_err}", exc_info=True) sys.exit(1) diff --git a/remote_rest/main.py b/remote_rest/main.py index 385c64ed..c850b6ac 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -1,7 +1,8 @@ import logging from contextlib import asynccontextmanager +import sys from typing import AsyncGenerator - +import os import uvicorn from attackmate.execexception import ExecException from fastapi import Depends, FastAPI, HTTPException, Request, status @@ -19,6 +20,11 @@ from .auth_utils import create_access_token, get_user_hash, verify_password from .schemas import TokenResponse + +CERT_DIR = os.path.dirname(os.path.abspath(__file__)) +KEY_FILE = os.path.join(CERT_DIR, "key.pem") +CERT_FILE = os.path.join(CERT_DIR, "cert.pem") + # Logging initialize_logger(debug=True, append_logs=False) initialize_output_logger(debug=True, append_logs=False) @@ -147,4 +153,16 @@ async def root(): return {'message': 'AttackMate API is running. Use /login to authenticate. See /docs.'} if __name__ == '__main__': - uvicorn.run('remote_rest.main:app', host='0.0.0.0', port=8000, reload=True, log_config=None,) + if not os.path.exists(KEY_FILE): + logger.critical(f"SSL Error: Key file not found at {KEY_FILE}") + sys.exit(1) + if not os.path.exists(CERT_FILE): + logger.critical(f"SSL Error: Certificate file not found at {CERT_FILE}") + sys.exit(1) + uvicorn.run('remote_rest.main:app', + host='0.0.0.0', + port=8443, + reload=False, + log_config=None, + ssl_keyfile=KEY_FILE, + ssl_certfile=CERT_FILE) From a778ff3acdaa6f10ec6aeb83681abd20ab85d3e4 Mon Sep 17 00:00:00 2001 From: kali Date: Wed, 7 May 2025 13:04:52 +0200 Subject: [PATCH 16/49] generate hash utility --- create_hashes.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 create_hashes.py diff --git a/create_hashes.py b/create_hashes.py new file mode 100644 index 00000000..355ab9cb --- /dev/null +++ b/create_hashes.py @@ -0,0 +1,17 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + + +users = { + 'user': 'user', + 'admin': 'admin', +} + +env_content = '' +print('\nCopy the following lines into your .env file:\n') +for username, plain_password in users.items(): + hashed_password = pwd_context.hash(plain_password) + env_line = f"USER_{username.upper()}_HASH=\"{hashed_password}\"" + print(env_line) + env_content += env_line + '\n' From 3318f6ad17584dd0a06881938e7a53b2e46825a3 Mon Sep 17 00:00:00 2001 From: kali Date: Wed, 7 May 2025 14:57:43 +0200 Subject: [PATCH 17/49] initial remote command and executor --- .../executors/remote/remoteexecutor.py | 25 +++++ src/attackmate/schemas/remote.py | 100 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/attackmate/executors/remote/remoteexecutor.py create mode 100644 src/attackmate/schemas/remote.py diff --git a/src/attackmate/executors/remote/remoteexecutor.py b/src/attackmate/executors/remote/remoteexecutor.py new file mode 100644 index 00000000..e194f813 --- /dev/null +++ b/src/attackmate/executors/remote/remoteexecutor.py @@ -0,0 +1,25 @@ +import logging +import os +import yaml +from typing import Dict, Any + +from attackmate.executors.executor_factory import executor_factory + +from attackmate.result import Result +from attackmate.execexception import ExecException +from attackmate.schemas.remote import AttackMateRemoteCommand +from attackmate.executors.baseexecutor import BaseExecutor +from attackmate.processmanager import ProcessManager +from attackmate.variablestore import VariableStore +from attackmate.command import CommandRegistry + + +@executor_factory.register_executor('remote') +class AttackMateRemoteExecutor(BaseExecutor): + def __init__(self, pm: ProcessManager, varstore: VariableStore, cmdconfig=None): + super().__init__(pm, varstore, cmdconfig) + # self.client is instantiated per command execution + self.logger = logging.getLogger('playbook') + + def log_command(self, command: AttackMateRemoteCommand): + self.logger.info(f"Executing REMOTE AttackMate command: Type='{command.type}', RemoteCmd='{command.cmd}' on server {command.server_url}") diff --git a/src/attackmate/schemas/remote.py b/src/attackmate/schemas/remote.py new file mode 100644 index 00000000..82443668 --- /dev/null +++ b/src/attackmate/schemas/remote.py @@ -0,0 +1,100 @@ +from pydantic import BaseModel, Field, ValidationInfo, field_validator +from typing import Literal, Optional, Dict, Any, List, Union + +from .base import BaseCommand +from ..command import CommandRegistry + +from .sleep import SleepCommand +from .shell import ShellCommand +from .vnc import VncCommand +from .setvar import SetVarCommand +from .include import IncludeCommand +from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand + +from .sliver import ( + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand, + SliverHttpsListenerCommand, + SliverGenerateCommand, +) +from .ssh import SSHCommand, SFTPCommand +from .http import WebServCommand, HttpClientCommand +from .father import FatherCommand +from .tempfile import TempfileCommand +from .debug import DebugCommand +from .regex import RegExCommand +from .browser import BrowserCommand + + +RemoteCommand = Union[ + BrowserCommand, + ShellCommand, + MsfModuleCommand, + MsfSessionCommand, + MsfPayloadCommand, + SleepCommand, + SSHCommand, + FatherCommand, + SFTPCommand, + DebugCommand, + SetVarCommand, + RegExCommand, + VncCommand, + TempfileCommand, + IncludeCommand, + WebServCommand, + HttpClientCommand, + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand, + SliverHttpsListenerCommand, + SliverGenerateCommand, + ] + + +@CommandRegistry.register('remote') +class AttackMateRemoteCommand(BaseCommand): + + type: Literal['remote'] + cmd: Literal['execute_command', 'execute_playbook_yaml', 'execute_playbook_file'] + server_url: str + cacert: str # configure this file path in some configs elsewhere? + user: str + password: str + playbook_yaml_content: Optional[str] + playbook_file_path: Optional[str] + remote_command: Optional[RemoteCommand] + + # Common command parameters (like background, only_if) from BaseCommand + # will be applied to the 'remote' command itself, not the remote_command directly -> remark in docs + + @field_validator('cmd') + @classmethod + def check_command_specific_fields(cls, v: str, info: ValidationInfo) -> str: + values = info.data + if v == 'execute_command' and not values.get('remote_command'): + raise ValueError("'remote_command' is required when cmd is 'execute_command'") + if v == 'execute_playbook_yaml' and not values.get('playbook_yaml_content'): + raise ValueError("'playbook_yaml_content' is required for 'execute_playbook_yaml'") + if v == 'execute_playbook_file' and not values.get('playbook_file_path'): + raise ValueError( + "'playbook_file_path' (path on remote server) is required for 'execute_playbook_file'" + ) + return v From 87ef8da1a3f47f10e9440590efc31671d12f8c2f Mon Sep 17 00:00:00 2001 From: annaerdi Date: Wed, 7 May 2025 16:28:59 +0200 Subject: [PATCH 18/49] Add C1 and C2 architecture diagrams --- docs/source/developing/architecture.rst | 35 ++++ docs/source/images/AttackMate-C1.png | Bin 0 -> 70760 bytes docs/source/images/AttackMate-C2.png | Bin 0 -> 166021 bytes docs/source/images/AttackMate-C4.drawio | 267 ++++++++++++++++++++++++ docs/source/index.rst | 1 + 5 files changed, 303 insertions(+) create mode 100644 docs/source/developing/architecture.rst create mode 100644 docs/source/images/AttackMate-C1.png create mode 100644 docs/source/images/AttackMate-C2.png create mode 100644 docs/source/images/AttackMate-C4.drawio diff --git a/docs/source/developing/architecture.rst b/docs/source/developing/architecture.rst new file mode 100644 index 00000000..5da12c27 --- /dev/null +++ b/docs/source/developing/architecture.rst @@ -0,0 +1,35 @@ +======================= +System Architecture (C4) +======================= + +This section presents the architecture of AttackMate using the +`C4 model `_, a visual framework for describing software architecture across different levels of detail. + +C1 – System Context Diagram +--------------------------- + +.. image:: ../images/AttackMate-C1.png + :width: 80% + :alt: System Context Diagram + +The System Context diagram shows how **AttackMate** fits into its environment. It illustrates the main user +(e.g., a pentester or researcher), the software systems it interacts with (e.g., vulnerable target systems, external +frameworks like Metasploit or Sliver), and the nature of those interactions. + + +C2 – Container Diagram +---------------------- + +This diagram shows how AttackMate is internally structured as a modular Python application. + +.. image:: ../images/AttackMate-C2.png + :alt: Container Diagram + +The system is centered around a core orchestration class that receives parsed playbook commands and delegates their +execution to appropriate components. It separates concerns between parsing, background task management, session handling, +and command execution, which makes it easy to extend with new command types or external tool integrations. + +Future diagrams (e.g., C3 or C4) could describe class-level and code-level structures if needed. + +.. note:: + The official C4 model site (https://c4model.com) provides detailed guidance if you're unfamiliar with this approach. diff --git a/docs/source/images/AttackMate-C1.png b/docs/source/images/AttackMate-C1.png new file mode 100644 index 0000000000000000000000000000000000000000..c586bc0a69ecf4c111b073068d8d5065a0e948fd GIT binary patch literal 70760 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

?a8G|AQ^o#I^$x!i`*w%LW(i( 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 fOr literal 0 HcmV?d00001 diff --git a/docs/source/images/AttackMate-C2.png b/docs/source/images/AttackMate-C2.png new file mode 100644 index 0000000000000000000000000000000000000000..f637ca667438f3854e563fed1a198951e6e3abbe GIT binary patch literal 166021 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�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

SB8|3a`C}AHoh!BydV1CS`W(Zljv>7SN^N54_|b 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/AttackMate-C4.drawio b/docs/source/images/AttackMate-C4.drawio new file mode 100644 index 00000000..b1764cf0 --- /dev/null +++ b/docs/source/images/AttackMate-C4.drawio @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/index.rst b/docs/source/index.rst index ec5598b0..6bd20055 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -37,6 +37,7 @@ Welcome to AttackMate's documentation! :maxdepth: 1 :caption: Developing: + developing/architecture developing/command developing/baseexecutor developing/integration From 38a431c83be89b9af97282bec59edeb8bffa5493 Mon Sep 17 00:00:00 2001 From: annaerdi Date: Wed, 7 May 2025 16:43:04 +0200 Subject: [PATCH 19/49] Update list of unsupported background-mode commands --- docs/source/playbook/commands/index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/playbook/commands/index.rst b/docs/source/playbook/commands/index.rst index bb1b51c9..bc1fa4fa 100644 --- a/docs/source/playbook/commands/index.rst +++ b/docs/source/playbook/commands/index.rst @@ -167,6 +167,8 @@ Every command, regardless of the type has the following general options: * MsfModuleCommand * IncludeCommand + * VncCommand + * BrowserCommand Background-Mode together with a session is currently not implemented for the following commands: From b59fa4f55c77d0d36250c329d35dcb247b0f4fe0 Mon Sep 17 00:00:00 2001 From: annaerdi Date: Thu, 8 May 2025 12:48:04 +0200 Subject: [PATCH 20/49] Allow any values for data field in HttpClientCommand --- src/attackmate/schemas/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attackmate/schemas/http.py b/src/attackmate/schemas/http.py index 1dbc741d..9cb37e22 100644 --- a/src/attackmate/schemas/http.py +++ b/src/attackmate/schemas/http.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Dict +from typing import Literal, Optional, Dict, Any from .base import BaseCommand, StringNumber from attackmate.command import CommandRegistry @@ -20,7 +20,7 @@ class HttpClientCommand(BaseCommand): output_headers: bool = False headers: Optional[Dict[str, str]] = None cookies: Optional[Dict[str, str]] = None - data: Optional[Dict[str, str]] = None + data: Optional[Dict[str, Any]] = None local_path: Optional[str] = None useragent: str = 'AttackMate' follow: bool = False From 44fb3c3abd2abf4632376eccda71000cb6b30e1c Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 13 May 2025 11:04:39 +0200 Subject: [PATCH 21/49] add loop command and type alias to remote command schema --- src/attackmate/schemas/remote.py | 72 +++++++++++++++++--------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/src/attackmate/schemas/remote.py b/src/attackmate/schemas/remote.py index 82443668..d1f00391 100644 --- a/src/attackmate/schemas/remote.py +++ b/src/attackmate/schemas/remote.py @@ -1,5 +1,7 @@ from pydantic import BaseModel, Field, ValidationInfo, field_validator -from typing import Literal, Optional, Dict, Any, List, Union +from typing import Literal, Optional, Dict, Any, List, Union, TypeAlias + +from attackmate.schemas.loop import LoopCommand from .base import BaseCommand from ..command import CommandRegistry @@ -9,6 +11,7 @@ from .vnc import VncCommand from .setvar import SetVarCommand from .include import IncludeCommand +from .loop import LoopCommand from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand from .sliver import ( @@ -35,38 +38,39 @@ from .browser import BrowserCommand -RemoteCommand = Union[ - BrowserCommand, - ShellCommand, - MsfModuleCommand, - MsfSessionCommand, - MsfPayloadCommand, - SleepCommand, - SSHCommand, - FatherCommand, - SFTPCommand, - DebugCommand, - SetVarCommand, - RegExCommand, - VncCommand, - TempfileCommand, - IncludeCommand, - WebServCommand, - HttpClientCommand, - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand, - SliverHttpsListenerCommand, - SliverGenerateCommand, - ] +RemoteCommand: TypeAlias = Union[ + BrowserCommand, + ShellCommand, + MsfModuleCommand, + MsfSessionCommand, + MsfPayloadCommand, + SleepCommand, + SSHCommand, + FatherCommand, + SFTPCommand, + DebugCommand, + SetVarCommand, + RegExCommand, + VncCommand, + TempfileCommand, + IncludeCommand, + WebServCommand, + HttpClientCommand, + LoopCommand, + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand, + SliverHttpsListenerCommand, + SliverGenerateCommand, +] @CommandRegistry.register('remote') @@ -83,7 +87,7 @@ class AttackMateRemoteCommand(BaseCommand): remote_command: Optional[RemoteCommand] # Common command parameters (like background, only_if) from BaseCommand - # will be applied to the 'remote' command itself, not the remote_command directly -> remark in docs + # will be applied to the 'remote' command itself, not the remote_command directly -> note in docs @field_validator('cmd') @classmethod From 352b9f4f4c1459e3b89306eeb80371bda3a7fb61 Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 13 May 2025 16:11:57 +0200 Subject: [PATCH 22/49] structure for remote command and executor --- docs/source/developing/baseexecutor.rst | 22 ++-- src/attackmate/executors/__init__.py | 2 + .../executors/remote/remoteexecutor.py | 114 +++++++++++++++++- src/attackmate/result.py | 3 +- src/attackmate/schemas/loop.py | 2 + src/attackmate/schemas/playbook.py | 2 + src/attackmate/schemas/remote.py | 36 +++--- 7 files changed, 144 insertions(+), 37 deletions(-) diff --git a/docs/source/developing/baseexecutor.rst b/docs/source/developing/baseexecutor.rst index d6cc799c..647ae9fd 100644 --- a/docs/source/developing/baseexecutor.rst +++ b/docs/source/developing/baseexecutor.rst @@ -6,7 +6,7 @@ Adding a New Executor Base Executor ================ -The ``BaseExecutor`` is the core class from which all executors in AttackMate inherit. +The ``BaseExecutor`` is the core class from which all executors in AttackMate inherit. It provides a structured approach to implementing custom executors. Key Features @@ -64,8 +64,8 @@ Overridable Methods The following methods can be overridden in custom executors to modify behavior: -**Command Execution** - +**Command Execution** + .. code-block:: python def _exec_cmd(self, command: BaseCommand) -> Result: @@ -74,24 +74,28 @@ The following methods can be overridden in custom executors to modify behavior: This is the core execution function and must be implemented in subclasses. It should return a ``Result`` object containing the execution outcome. -.. note:: +.. note:: - The ``_exec_cmd()`` method **must** be implemented in any subclass of ``BaseExecutor``. - This method defines the core execution logic for the command and is responsible for returning a ``Result`` object. + The ``_exec_cmd()`` method **must** be implemented in any subclass of ``BaseExecutor``. + This method defines the core execution logic for the command and is responsible for returning a ``Result`` object. -**Logging Functions** +**Logging Functions** The methods ``log_command``, ``log_matadata`` and ``log_json`` log command execution details and can be overridden for custom logging formats. -**Command Execution Flow** +**Command Execution Flow** The ``run()`` method defines the high-level execution flow of a command. It includes condition checking, logging, and calling the actual execution logic. -**Output Handling** +**Output Handling** The ``save_output()`` function manages saving output to a file. It can be overridden to implement alternative storage methods. +executor __init__.py +-------------------- +.. note:: + Add the new executor to the ``__all__`` list in the ``__init__.py`` file of the ``attackmate.executors`` module. diff --git a/src/attackmate/executors/__init__.py b/src/attackmate/executors/__init__.py index 955058f0..9312ed95 100644 --- a/src/attackmate/executors/__init__.py +++ b/src/attackmate/executors/__init__.py @@ -1,5 +1,6 @@ from .browser.browserexecutor import BrowserExecutor from .shell.shellexecutor import ShellExecutor +from .remote.remoteexecutor import RemoteExecutor from .ssh.sshexecutor import SSHExecutor from .metasploit.msfsessionexecutor import MsfSessionExecutor from .metasploit.msfpayloadexecutor import MsfPayloadExecutor @@ -22,6 +23,7 @@ __all__ = [ + 'RemoteExecutor' 'BrowserExecutor', 'ShellExecutor', 'SSHExecutor', diff --git a/src/attackmate/executors/remote/remoteexecutor.py b/src/attackmate/executors/remote/remoteexecutor.py index e194f813..dc04415b 100644 --- a/src/attackmate/executors/remote/remoteexecutor.py +++ b/src/attackmate/executors/remote/remoteexecutor.py @@ -1,25 +1,131 @@ import logging import os import yaml -from typing import Dict, Any +import json +from typing import Dict, Any, Optional from attackmate.executors.executor_factory import executor_factory +from attackmate.remote_client import RemoteAttackMateClient from attackmate.result import Result from attackmate.execexception import ExecException from attackmate.schemas.remote import AttackMateRemoteCommand from attackmate.executors.baseexecutor import BaseExecutor from attackmate.processmanager import ProcessManager from attackmate.variablestore import VariableStore -from attackmate.command import CommandRegistry @executor_factory.register_executor('remote') -class AttackMateRemoteExecutor(BaseExecutor): +class RemoteExecutor(BaseExecutor): def __init__(self, pm: ProcessManager, varstore: VariableStore, cmdconfig=None): super().__init__(pm, varstore, cmdconfig) # self.client is instantiated per command execution self.logger = logging.getLogger('playbook') + # Client class is instantiated per command execution with server_url context + self._clients_cache: Dict[str, RemoteAttackMateClient] = {} # Cache clients per server_url def log_command(self, command: AttackMateRemoteCommand): - self.logger.info(f"Executing REMOTE AttackMate command: Type='{command.type}', RemoteCmd='{command.cmd}' on server {command.server_url}") + self.logger.info( + f"Executing REMOTE AttackMate command: Type='{command.type}', " + f"RemoteCmd='{command.cmd}' on server {command.server_url}'" + ) + + def _get_client(self, command_config: AttackMateRemoteCommand) -> RemoteAttackMateClient: + """Gets or creates a client instance for the given server URL.""" + server_url = self.varstore.substitute(command_config.server_url) # maybe better way? + if server_url not in self._clients_cache: + self.logger.info(f"Creating new remote client for server: {server_url}") + # Substitute user/password from local varstore if they are variables + user = self.varstore.substitute(command_config.user) if command_config.user else None + password = self.varstore.substitute(command_config.password) if command_config.password else None + cacert = self.varstore.substitute(command_config.cacert) if command_config.cacert else None + + self._clients_cache[server_url] = RemoteAttackMateClient( + server_url=server_url, + cacert=cacert, + username=user, + password=password + # maybe make Timeout configurable via AttackMateRemoteCommand? + ) + return self._clients_cache[server_url] + + def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: + client = self._get_client(command) + response_data: Optional[Dict[str, Any]] = None + error_message: Optional[str] = None + success: bool = False + stdout_str: Optional[str] = None + return_code: int = 1 # Default to error + + # TODO make this properly configurable in AttackMateRemoteCommand for logging on server + api_call_debug_flag = False + if hasattr(command, 'debug') and isinstance(command.debug, bool): # add 'debug' as parameter to AttackMateRemoteCommand + api_call_debug_flag = command.debug + + try: + if command.cmd == 'execute_playbook_yaml': + # Substitute local vars into playbook content before sending? + # TODO decide on this, or if substitution happens only on the server side + + try: + with open(command.playbook_yaml_content, 'r') as f: + yaml_content = f.read() + # TODO decide on varibale subsitution here + # final_yaml_content = self.varstore.substitute(yaml_content) + response_data = client.execute_remote_playbook_yaml(yaml_content, debug=api_call_debug_flag) + except Exception as e: + raise ExecException(f"Failed to read local file '{command.playbook_yaml_content}': {e}") + + elif command.cmd == 'execute_playbook_file': + response_data = client.execute_remote_playbook_file(command.playbook_file_path, + debug=api_call_debug_flag) + + elif command.cmd == 'execute_command': + response_data = client.execute_remote_command( + command_pydantic_model=command.remote_command, + debug=api_call_debug_flag + ) + + # Process response + if response_data: + cmd_result = response_data.get('result', {}) + if cmd_result: + success = cmd_result.get('success', False) + stdout_str = cmd_result.get('stdout') + return_code = cmd_result.get('returncode', 1 if not success else 0) + if not success and 'error_message' in cmd_result: + error_message = cmd_result['error_message'] + elif 'success' in response_data: # For playbook responses + success = response_data.get('success', False) + stdout_str = json.dumps(response_data, indent=2) # Whole response as stdout + return_code = 0 if success else 1 + if not success: + error_message = response_data.get('message') + else: + error_message = 'Received unexpected response structure from remote server.' + stdout_str = json.dumps(response_data, indent=2) + success = False + return_code = 1 + + else: # No response_data from client call + error_message = 'No response received from remote server (client communication failed).' + success = False + return_code = 1 + + except ExecException: + raise + except Exception as e: + error_message = f"Remote executor encountered an error: {e}" + self.logger.error(error_message, exc_info=True) + success = False + return_code = 1 + + # Finalize output, executir return whatever the remote server returns + if error_message and stdout_str and 'Error:' not in stdout_str: + stdout_str = f"Error: {error_message}\n\nOutput/Response:\n{stdout_str}" + elif error_message: + stdout_str = f"Error: {error_message}" + elif stdout_str is None: + stdout_str = 'Operation completed.' if success else 'Operation failed.' + + return Result(stdout_str, return_code if return_code is not None else (0 if success else 1)) diff --git a/src/attackmate/result.py b/src/attackmate/result.py index 6675d0f3..3fde98ba 100644 --- a/src/attackmate/result.py +++ b/src/attackmate/result.py @@ -26,5 +26,4 @@ def __init__(self, stdout, returncode): self.returncode = returncode def __repr__(self): - return f"Result(stdout={repr(self.stdout)}, returncode={self.returncode})" - + return f"Result(stdout={repr(self.stdout)}, returncode={self.returncode})" diff --git a/src/attackmate/schemas/loop.py b/src/attackmate/schemas/loop.py index 77ada26b..e626b9ac 100644 --- a/src/attackmate/schemas/loop.py +++ b/src/attackmate/schemas/loop.py @@ -26,6 +26,7 @@ SliverGenerateCommand, ) from .ssh import SSHCommand, SFTPCommand +from .remote import AttackMateRemoteCommand from .http import WebServCommand, HttpClientCommand from .father import FatherCommand from .tempfile import TempfileCommand @@ -36,6 +37,7 @@ Commands = List[ Union[ + AttackMateRemoteCommand, BrowserCommand, ShellCommand, MsfModuleCommand, diff --git a/src/attackmate/schemas/playbook.py b/src/attackmate/schemas/playbook.py index d7aaac95..717ce8ec 100644 --- a/src/attackmate/schemas/playbook.py +++ b/src/attackmate/schemas/playbook.py @@ -32,6 +32,7 @@ from .vnc import VncCommand from .json import JsonCommand from .browser import BrowserCommand +from .remote import AttackMateRemoteCommand Command = Union[ BrowserCommand, @@ -66,6 +67,7 @@ SliverHttpsListenerCommand, SliverGenerateCommand, VncCommand, + AttackMateRemoteCommand ] diff --git a/src/attackmate/schemas/remote.py b/src/attackmate/schemas/remote.py index d1f00391..5ae57a71 100644 --- a/src/attackmate/schemas/remote.py +++ b/src/attackmate/schemas/remote.py @@ -1,17 +1,14 @@ -from pydantic import BaseModel, Field, ValidationInfo, field_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator from typing import Literal, Optional, Dict, Any, List, Union, TypeAlias -from attackmate.schemas.loop import LoopCommand - from .base import BaseCommand -from ..command import CommandRegistry +from attackmate.command import CommandRegistry from .sleep import SleepCommand from .shell import ShellCommand from .vnc import VncCommand from .setvar import SetVarCommand from .include import IncludeCommand -from .loop import LoopCommand from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand from .sliver import ( @@ -56,7 +53,6 @@ IncludeCommand, WebServCommand, HttpClientCommand, - LoopCommand, SliverSessionCDCommand, SliverSessionLSCommand, SliverSessionNETSTATCommand, @@ -82,23 +78,19 @@ class AttackMateRemoteCommand(BaseCommand): cacert: str # configure this file path in some configs elsewhere? user: str password: str - playbook_yaml_content: Optional[str] - playbook_file_path: Optional[str] - remote_command: Optional[RemoteCommand] + playbook_yaml_content: Optional[str] = None + playbook_file_path: Optional[str] = None + remote_command: Optional[RemoteCommand] = None # Common command parameters (like background, only_if) from BaseCommand # will be applied to the 'remote' command itself, not the remote_command directly -> note in docs - @field_validator('cmd') - @classmethod - def check_command_specific_fields(cls, v: str, info: ValidationInfo) -> str: - values = info.data - if v == 'execute_command' and not values.get('remote_command'): - raise ValueError("'remote_command' is required when cmd is 'execute_command'") - if v == 'execute_playbook_yaml' and not values.get('playbook_yaml_content'): - raise ValueError("'playbook_yaml_content' is required for 'execute_playbook_yaml'") - if v == 'execute_playbook_file' and not values.get('playbook_file_path'): - raise ValueError( - "'playbook_file_path' (path on remote server) is required for 'execute_playbook_file'" - ) - return v + @model_validator(mode='after') + 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_yaml' and not self.playbook_yaml_content: + raise ValueError("playbook_yaml_content must be provided when cmd is 'execute_playbook_yaml'") + if self.cmd == 'execute_playbook_file' and not self.playbook_file_path: + raise ValueError("playbook_file_path must be provided when cmd is 'execute_playbook_file'") + return self From c9ed739c82a5abbc616a08c6cac55d2ff8214d74 Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 13 May 2025 16:14:18 +0200 Subject: [PATCH 23/49] initial remote client class --- src/attackmate/remote_client.py | 194 ++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/attackmate/remote_client.py diff --git a/src/attackmate/remote_client.py b/src/attackmate/remote_client.py new file mode 100644 index 00000000..903d4724 --- /dev/null +++ b/src/attackmate/remote_client.py @@ -0,0 +1,194 @@ +import httpx +import logging +import os +import json +from typing import Dict, Any, Optional + +_active_sessions: Dict[str, Dict[str, str]] = {} +DEFAULT_TIMEOUT = 60.0 # what should the timeout be for requests? what about background? +# make timeout configurable? + +logger = logging.getLogger('playbook') + + +class RemoteAttackMateClient: + """ + Client to interact with a remote AttackMate REST API. + Handles authentication and token management internally per server URL. + """ + def __init__( + self, + server_url: str, + cacert: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + ): + self.server_url = server_url.rstrip('/') + self.username = username + self.password = password + self.timeout_config = httpx.Timeout(10.0, connect=5.0, read=timeout) + + if cacert: + if os.path.exists(cacert): + self.verify_ssl = cacert + logger.info(f"Client will verify {self.server_url} SSL using CA: {cacert}") + else: + logger.error(f"CA certificate file not found: {cacert}.") + logger.debug(f"RemoteClient initialized for {self.server_url}") + + def _get_session_token(self) -> Optional[str]: + """Retrieves a valid token for the server_url from memory, logs in if necessary.""" + # There is a token for that server and user in memory, use that + session_data = _active_sessions.get(self.server_url) + if session_data and session_data.get('user') == self.username: + logger.debug(f"Using existing token for {self.server_url} by user {session_data['user']}") + return session_data['token'] + # if not try login with credentials + else: + if self.username and self.password: + return self._login(self.username, self.password) + return None + + def _login(self, username: str, password: str) -> Optional[str]: + """Internal login method, stores token.""" + login_url = f"{self.server_url}/login" + logger.info(f"Attempting login to {login_url} for user '{username}'...") + try: + with httpx.Client(verify=self.verify_ssl, timeout=self.timeout_config) as client: + response = client.post(login_url, data={'username': username, 'password': password}) + # does this need to be form data? + + response.raise_for_status() + data = response.json() + token = data.get('access_token') + + if token: + # with a session_lock? + _active_sessions[self.server_url] = { + 'token': token, + 'user': username + } + logger.info(f"Login successful for '{username}' at {self.server_url}. Token stored.") + return token + else: + logger.error(f"Login to {self.server_url} succeeded but no token received.") + return None + except httpx.HTTPStatusError as e: + logger.error(f"Login failed for '{username}' at {self.server_url}: {e.response.status_code}") + return None + except Exception as e: + logger.error(f"Login request to {self.server_url} failed: {e}", exc_info=True) + return None + + # Common Method to make request + def _make_request( + self, + method: str, + endpoint: str, + json_data: Optional[Dict[str, Any]] = None, + content_data: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + """Makes an authenticated request, handles token renewal implicitly by server.""" + token = self._get_session_token() + if not token: + # Attempt login if credentials are set on the client instance + if self.username and self.password: + logger.info( + f"No active token for {self.server_url}, try login with provided credentials." + ) + token = self._login(self.username, self.password) + if not token: + logger.error(f"Auth required for {self.server_url} but no token available and login failed") + return None # Or raise an AuthException? + + headers = {'X-Auth-Token': token} + if content_data: + headers['Content-Type'] = 'application/yaml' + + url = f"{self.server_url}/{endpoint.lstrip('/')}" + logger.debug(f"Making {method.upper()} request to {url}") + try: + with httpx.Client(verify=self.verify_ssl, timeout=self.timeout_config) as client: + if method.upper() == 'POST': + if content_data: + # sending yaml playbook content + response = client.post(url, content=content_data, headers=headers, params=params) + else: + # sending command or file path + response = client.post(url, json=json_data, headers=headers, params=params) + elif method.upper() == 'GET': + response = client.get(url, headers=headers, params=params) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() + response_data = response.json() + + # Server should renew token on any successful authenticated API call. + # Client just uses the token. Server sends back a new token if renewed, + # The current_token field in the response from server use to update client's token. + new_token_from_response = response_data.get('current_token') + if new_token_from_response and new_token_from_response != token: + logger.info(f"Server returned a renewed token for {self.server_url}. Updating client.") + _active_sessions[self.server_url]['token'] = new_token_from_response + return response_data + + except httpx.HTTPStatusError as e: + logger.error(f"API Error ({method} {url}): {e.response.status_code}") + if e.response.status_code == 401: # Unauthorized + logger.warning(f"Token likely expired or invalid for {self.server_url}. Clearing token") + # with _session_lock: + _active_sessions.pop(self.server_url, None) # Clear session on 401 + return None # Or raise custom Error? + except httpx.RequestError as e: + logger.error(f"Request Error ({method} {url}): {e}") + return None + except json.JSONDecodeError: + logger.error(f"JSON Decode Error ({method} {url}). Response: {response.text}") + return None + except Exception as e: + logger.error(f"Unexpected error during API request ({method} {url}): {e}", exc_info=True) + return None + + # API Methods all use the _make_request method + def execute_remote_playbook_yaml( + self, playbook_yaml_content: str, debug: bool = False + ) -> Optional[Dict[str, Any]]: + return self._make_request( + method='POST', + endpoint='playbooks/execute/yaml', + content_data=playbook_yaml_content, + params={'debug': True} if debug else None + ) + + def execute_remote_playbook_file( + self, server_playbook_path: str, debug: bool = False + ) -> Optional[Dict[str, Any]]: + return self._make_request( + method='POST', + endpoint='playbooks/execute/file', + json_data={'file_path': server_playbook_path}, + params={'debug': True} if debug else None + ) + + def execute_remote_command( + self, + command_pydantic_model, + debug: bool = False + ) -> Optional[Dict[str, Any]]: + # get the correct enpoint + command_type_for_path = command_pydantic_model.type.replace('_', '-') # API path has hyphens + endpoint = f"command/{command_type_for_path}" + + # Convert Pydantic model to dict for JSON body + # handle None values for optional fields (exclude_none=True) + command_body_dict = command_pydantic_model.model_dump(exclude_none=True) + + return self._make_request( + method='POST', + endpoint=endpoint, + json_data=command_body_dict, + params={'debug': True} if debug else None + ) From 614c1112a3c07b858161e8f746e628dd08e5f7fe Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 19 May 2025 15:23:25 +0200 Subject: [PATCH 24/49] use argon2 --- create_hashes.py | 5 ++--- remote_rest/README.md | 3 +-- remote_rest/auth_utils.py | 4 ++-- remote_rest/create_hashes.py | 5 ++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/create_hashes.py b/create_hashes.py index 355ab9cb..f01f898c 100644 --- a/create_hashes.py +++ b/create_hashes.py @@ -1,11 +1,10 @@ from passlib.context import CryptContext -pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') +pwd_context = CryptContext(schemes=['argon2'], deprecated='auto') users = { - 'user': 'user', - 'admin': 'admin', + 'testuser': 'testuser', } env_content = '' diff --git a/remote_rest/README.md b/remote_rest/README.md index 85310eb6..507053b0 100644 --- a/remote_rest/README.md +++ b/remote_rest/README.md @@ -1,5 +1,4 @@ -pip install fastapi uvicorn httpx PyYAML pydantic -bcrypt==3.2.2 !! otherwise passlib complains +pip install fastapi uvicorn httpx PyYAML pydantic argon2_cffi uvicorn remote_rest.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/remote_rest/auth_utils.py b/remote_rest/auth_utils.py index 893cbb48..cba7d8f0 100644 --- a/remote_rest/auth_utils.py +++ b/remote_rest/auth_utils.py @@ -15,10 +15,10 @@ TOKEN_EXPIRE_MINUTES = int(os.getenv('TOKEN_EXPIRE_MINUTES', 30)) API_KEY_HEADER_NAME = 'X-Auth-Token' api_key_header_scheme = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=True) -pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') +pwd_context = CryptContext(schemes=['argon2'], deprecated='auto') # In-Memory token Store -# token looks like this {"username": str, "expires": datetime} +# token looks like this token : {"username": str, "expires": datetime} # state is lost on server restart. # Not inherently thread-safe for multi-worker setups without locks ? ACTIVE_TOKENS: Dict[str, Dict[str, Any]] = {} diff --git a/remote_rest/create_hashes.py b/remote_rest/create_hashes.py index 433af170..c073f928 100644 --- a/remote_rest/create_hashes.py +++ b/remote_rest/create_hashes.py @@ -2,12 +2,11 @@ from passlib.context import CryptContext -pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') +pwd_context = CryptContext(schemes=['argon2'], deprecated='auto') users = { - 'user': 'user', - 'admin': 'admin', + 'testuser': 'testuser', } env_content = '' From 11f6095f3c49f8b9bab9fd163f75588277d0bd50 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 19 May 2025 15:35:57 +0200 Subject: [PATCH 25/49] handle json logging of remote_command --- src/attackmate/executors/baseexecutor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/attackmate/executors/baseexecutor.py b/src/attackmate/executors/baseexecutor.py index fb673c44..1e33ee08 100644 --- a/src/attackmate/executors/baseexecutor.py +++ b/src/attackmate/executors/baseexecutor.py @@ -3,6 +3,8 @@ from datetime import datetime from typing import Any from collections import OrderedDict + +from pydantic import BaseModel from attackmate.executors.features.cmdvars import CmdVars from attackmate.executors.features.exitonerror import ExitOnError from attackmate.executors.features.looper import Looper @@ -117,14 +119,15 @@ def make_command_serializable(self, command, time): command_dict['parameters'] = dict() for key, value in command.__dict__.items(): - if key not in command_dict and key != 'commands': + if key not in command_dict and key != 'commands' and key != 'remote_command': command_dict['parameters'][key] = value # Handle nested "commands" recursively if key == 'commands' and isinstance(value, list): command_dict['parameters']['commands'] = [ self.make_command_serializable(sub_command, time) for sub_command in value ] - + if key == 'remote_command' and isinstance(value, BaseModel): + command_dict['parameters']['remote_command'] = self.make_command_serializable(value, time) return command_dict def save_output(self, command: BaseCommand, result: Result): From 11cf68c8fdfa2c09d0198f803a6dbb6221f4c395 Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 22 May 2025 08:53:09 +0200 Subject: [PATCH 26/49] fix types --- src/attackmate/executors/remote/remoteexecutor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/attackmate/executors/remote/remoteexecutor.py b/src/attackmate/executors/remote/remoteexecutor.py index dc04415b..64b9077f 100644 --- a/src/attackmate/executors/remote/remoteexecutor.py +++ b/src/attackmate/executors/remote/remoteexecutor.py @@ -1,6 +1,4 @@ import logging -import os -import yaml import json from typing import Dict, Any, Optional @@ -68,6 +66,8 @@ def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: # TODO decide on this, or if substitution happens only on the server side try: + if not command.playbook_yaml_content: + raise ExecException('playbook_yaml_content cannot be None.') with open(command.playbook_yaml_content, 'r') as f: yaml_content = f.read() # TODO decide on varibale subsitution here @@ -77,6 +77,8 @@ def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: raise ExecException(f"Failed to read local file '{command.playbook_yaml_content}': {e}") elif command.cmd == 'execute_playbook_file': + if not command.playbook_file_path: + raise ExecException("playbook_file_path cannot be None for 'execute_playbook_file' command.") response_data = client.execute_remote_playbook_file(command.playbook_file_path, debug=api_call_debug_flag) @@ -95,9 +97,9 @@ def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: return_code = cmd_result.get('returncode', 1 if not success else 0) if not success and 'error_message' in cmd_result: error_message = cmd_result['error_message'] - elif 'success' in response_data: # For playbook responses + elif 'success' in response_data: # For playbook responses success = response_data.get('success', False) - stdout_str = json.dumps(response_data, indent=2) # Whole response as stdout + stdout_str = json.dumps(response_data, indent=2) # Whole response as stdout return_code = 0 if success else 1 if not success: error_message = response_data.get('message') From c0de45843ba113d47406241b8ec1168fecee9f3f Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 22 May 2025 11:49:42 +0200 Subject: [PATCH 27/49] add command_delay to config --- docs/source/configuration/command_config.rst | 12 ++++++++++- docs/source/configuration/index.rst | 1 + src/attackmate/attackmate.py | 21 ++++++++++---------- src/attackmate/schemas/config.py | 3 ++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/source/configuration/command_config.rst b/docs/source/configuration/command_config.rst index 231b4a5d..2d01a2e6 100644 --- a/docs/source/configuration/command_config.rst +++ b/docs/source/configuration/command_config.rst @@ -2,13 +2,15 @@ cmd_config ========== -Stores global variables for command options. These are settings for **all** commands. +Stores global variables for command options. +These are settings for **all** commands. .. code-block:: yaml ### cmd_config: loop_sleep: 5 + command_delay: 0 .. confval:: loop_sleep @@ -19,3 +21,11 @@ Stores global variables for command options. These are settings for **all** comm :type: int :default: 5 + +.. confval:: command_delay + + This delay in seconds is applied to all commands in the playbook. + It is not applied to debug, setvar and sleep commands. + + :type: float + :default: 0 diff --git a/docs/source/configuration/index.rst b/docs/source/configuration/index.rst index a40380ca..e2fe41f5 100644 --- a/docs/source/configuration/index.rst +++ b/docs/source/configuration/index.rst @@ -25,6 +25,7 @@ sliver and metasploit: ### cmd_config: loop_sleep: 5 + command_delay: 0 msf_config: password: securepassword diff --git a/src/attackmate/attackmate.py b/src/attackmate/attackmate.py index 72230ad1..6d90be5d 100644 --- a/src/attackmate/attackmate.py +++ b/src/attackmate/attackmate.py @@ -8,6 +8,7 @@ configuration. """ +import time from typing import Dict, Optional import logging from attackmate.result import Result @@ -21,7 +22,6 @@ import asyncio - class AttackMate: def __init__( self, @@ -89,10 +89,14 @@ def _get_executor(self, command_type: str) -> BaseExecutor: return self.executors[command_type] def _run_commands(self, commands: Commands): + delay = self.pyconfig.cmd_config.command_delay or 0 + self.logger.info(f'Delay before commands: {delay} seconds') 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'): + time.sleep(delay) executor.run(command) def run_command(self, command: Command) -> Result: @@ -101,28 +105,25 @@ def run_command(self, command: Command) -> Result: if executor: result = executor.run(command) return result if result else Result(None, None) - + def clean_session_stores(self): self.logger.warning('Cleaning up session stores') # msf - if (msf_module_executor := self.executors.get("msf-module")): + if (msf_module_executor := self.executors.get('msf-module')): msf_module_executor.cleanup() - if (msf_session_executor := self.executors.get("msf-session")): + if (msf_session_executor := self.executors.get('msf-session')): msf_session_executor.cleanup() # ssh - if (ssh_executor := self.executors.get("ssh")): + if (ssh_executor := self.executors.get('ssh')): ssh_executor.cleanup() # vnc - if (vnc_executor := self.executors.get("vnc")): + if (vnc_executor := self.executors.get('vnc')): vnc_executor.cleanup() # sliver - if (sliver_executor := self.executors.get("sliver-session")): + if (sliver_executor := self.executors.get('sliver-session')): loop = asyncio.get_event_loop() loop.run_until_complete(sliver_executor.cleanup()) - - - def main(self): """The main function diff --git a/src/attackmate/schemas/config.py b/src/attackmate/schemas/config.py index 18079103..f21264dc 100644 --- a/src/attackmate/schemas/config.py +++ b/src/attackmate/schemas/config.py @@ -16,9 +16,10 @@ class MsfConfig(BaseModel): class CommandConfig(BaseModel): loop_sleep: int = 5 + command_delay: float = 0 class Config(BaseModel): sliver_config: SliverConfig = SliverConfig(config_file=None) msf_config: MsfConfig = MsfConfig(password=None) - cmd_config: CommandConfig = CommandConfig(loop_sleep=5) + cmd_config: CommandConfig = CommandConfig(loop_sleep=5, command_delay=0) From bb7b167d1a921d20552f69519be66dcc61fac5c0 Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 26 Jun 2025 13:19:27 +0200 Subject: [PATCH 28/49] test command delay --- test/units/test_commanddelay.py | 91 +++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/units/test_commanddelay.py diff --git a/test/units/test_commanddelay.py b/test/units/test_commanddelay.py new file mode 100644 index 00000000..228ecdc7 --- /dev/null +++ b/test/units/test_commanddelay.py @@ -0,0 +1,91 @@ +import time +from attackmate.attackmate import AttackMate +from attackmate.schemas.config import Config, CommandConfig +from attackmate.schemas.playbook import Playbook +from attackmate.schemas.debug import DebugCommand +from attackmate.schemas.setvar import SetVarCommand +from attackmate.schemas.shell import ShellCommand +from attackmate.schemas.sleep import SleepCommand + + +def test_command_delay_is_applied(): + """ + Tests that command_delay is applied between applicable commands. + """ + delay = 0.2 + num_commands = 3 + playbook = Playbook( + commands=[ + ShellCommand(type='shell', cmd='echo 1'), + ShellCommand(type='shell', cmd='echo 2'), + ShellCommand(type='shell', cmd='echo 3'), + ] + ) + config = Config(cmd_config=CommandConfig(command_delay=delay)) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + start_time = time.monotonic() + attackmate_instance._run_commands(attackmate_instance.playbook.commands) + end_time = time.monotonic() + elapsed_time = end_time - start_time + + expected_minimum_time = num_commands * delay + # Allow for command execution overhead + expected_maximum_time = expected_minimum_time + 0.5 + + assert elapsed_time >= expected_minimum_time, ( + f"Execution faster ({elapsed_time:.4f}s) than the minimum expected delay " + f"({expected_minimum_time:.4f}s)." + ) + assert elapsed_time < expected_maximum_time, ( + f"Execution slower ({elapsed_time:.4f}s) than expected." + ) + + +def test_zero_command_delay(): + """ + Tests that no delay is applied when command_delay is 0. + """ + playbook = Playbook( + commands=[ + ShellCommand(type='shell', cmd='echo 1'), + ShellCommand(type='shell', cmd='echo 2'), + ] + ) + config = Config(cmd_config=CommandConfig(command_delay=0)) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + start_time = time.monotonic() + attackmate_instance._run_commands(attackmate_instance.playbook.commands) + end_time = time.monotonic() + elapsed_time = end_time - start_time + + # With no delay, execution should be very fast. + assert elapsed_time < 0.1, ( + f"Execution with no delay took too long: {elapsed_time:.4f}s." + ) + + +def test_delay_is_not_applied_for_exempt_commands(): + """ + Tests that delay is skipped for 'sleep', 'debug', and 'setvar' commands. + """ + playbook = Playbook( + commands=[ + SetVarCommand(type='setvar', cmd='x', variable='y'), + DebugCommand(type='debug', cmd='test message'), + SleepCommand(type='sleep', seconds=0), + ] + ) + # This delay should be ignored + config = Config(cmd_config=CommandConfig(command_delay=5)) + attackmate_instance = AttackMate(playbook=playbook, config=config) + + start_time = time.monotonic() + attackmate_instance._run_commands(attackmate_instance.playbook.commands) + end_time = time.monotonic() + elapsed_time = end_time - start_time + + assert elapsed_time < 0.1, ( + f"Execution with exempt commands took too long: {elapsed_time:.4f}s." + ) From d37b8e2bbc3abebf0a6fbf42dd3a97eb3b1486ec Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 26 Jun 2025 13:47:27 +0200 Subject: [PATCH 29/49] json logger tests --- test/units/test_logging.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/units/test_logging.py b/test/units/test_logging.py index a7ba684e..f6d00721 100644 --- a/test/units/test_logging.py +++ b/test/units/test_logging.py @@ -1,6 +1,7 @@ -import pytest from unittest.mock import patch, MagicMock from attackmate.logging_setup import create_file_handler +from attackmate.logging_setup import initialize_json_logger +import logging @patch('attackmate.logging_setup.logging.FileHandler') @@ -31,3 +32,38 @@ def test_create_file_handler_write_mode(MockFileHandler): MockFileHandler.assert_called_with(file_name, mode='w') mock_handler.setFormatter.assert_called_with(formatter) + +@patch('attackmate.logging_setup.logging.getLogger') +@patch('attackmate.logging_setup.logging.Formatter') +@patch('attackmate.logging_setup.create_file_handler') +def test_initialize_json_logger_when_enabled(mock_create_handler, mock_formatter, mock_get_logger): + """ + Tests that the JSON logger is configured correctly when enabled. + """ + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + mock_file_handler = MagicMock() + mock_create_handler.return_value = mock_file_handler + mock_formatter_instance = MagicMock() + mock_formatter.return_value = mock_formatter_instance + + json_logger = initialize_json_logger(json=True, append_logs=True) + + mock_get_logger.assert_called_once_with('json') + mock_logger.setLevel.assert_called_once_with(logging.DEBUG) + + mock_create_handler.assert_called_once_with('attackmate.json', True, mock_formatter_instance) + mock_logger.addHandler.assert_called_once_with(mock_file_handler) + + +@patch('attackmate.logging_setup.logging.getLogger') +@patch('attackmate.logging_setup.create_file_handler') +def test_initialize_json_logger_when_disabled(mock_create_handler, mock_get_logger): + """ + Tests that the JSON logger is not created and returns None when disabled. + """ + returned_value = initialize_json_logger(json=False, append_logs=True) + + assert returned_value is None + mock_get_logger.assert_not_called() + mock_create_handler.assert_not_called() From 749928690d0d093b0ee6f5ce4aa546825fe1a666 Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 26 Jun 2025 13:49:24 +0200 Subject: [PATCH 30/49] json logger tests --- test/units/test_logging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/units/test_logging.py b/test/units/test_logging.py index f6d00721..517fe683 100644 --- a/test/units/test_logging.py +++ b/test/units/test_logging.py @@ -33,6 +33,7 @@ def test_create_file_handler_write_mode(MockFileHandler): MockFileHandler.assert_called_with(file_name, mode='w') mock_handler.setFormatter.assert_called_with(formatter) + @patch('attackmate.logging_setup.logging.getLogger') @patch('attackmate.logging_setup.logging.Formatter') @patch('attackmate.logging_setup.create_file_handler') From c67e943cedfd9be90f7b5aa689bb60a3c708850f Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 26 Jun 2025 14:38:27 +0200 Subject: [PATCH 31/49] small fixes --- src/attackmate/playbook_parser.py | 6 +++--- src/attackmate/result.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/attackmate/playbook_parser.py b/src/attackmate/playbook_parser.py index 5c128a3e..40a3ba57 100644 --- a/src/attackmate/playbook_parser.py +++ b/src/attackmate/playbook_parser.py @@ -118,7 +118,7 @@ def parse_playbook(playbook_file: str, logger: logging.Logger) -> Playbook: target_file = default_playbook_location / playbook_file_path else: logger.error( - f"Error: Playbook file not found under '/non/existent/path/playbook.yml' or in the current directory or in /etc/attackmate/playbooks" + "Error: Playbook file not found under '/non/existent/path/playbook.yml' or in the current directory or in /etc/attackmate/playbooks" ) exit(1) @@ -130,8 +130,8 @@ def parse_playbook(playbook_file: str, logger: logging.Logger) -> Playbook: try: with open(target_file) as f: - pb_yaml = yaml.safe_load(f) - playbook_object = Playbook.model_validate(pb_yaml) + playbook_yaml = yaml.safe_load(f) + playbook_object = Playbook.model_validate(playbook_yaml) return playbook_object except OSError: logger.error(f'Error: Could not open playbook file {target_file}') diff --git a/src/attackmate/result.py b/src/attackmate/result.py index 6675d0f3..3fde98ba 100644 --- a/src/attackmate/result.py +++ b/src/attackmate/result.py @@ -26,5 +26,4 @@ def __init__(self, stdout, returncode): self.returncode = returncode def __repr__(self): - return f"Result(stdout={repr(self.stdout)}, returncode={self.returncode})" - + return f"Result(stdout={repr(self.stdout)}, returncode={self.returncode})" From 83fee892b038eb8e1f8ade11d6461d9b78da089f Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 15 Jul 2025 15:46:28 +0200 Subject: [PATCH 32/49] add json to remote logging --- remote_rest/log_utils.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/remote_rest/log_utils.py b/remote_rest/log_utils.py index 36bcfe3a..5a1a3b73 100644 --- a/remote_rest/log_utils.py +++ b/remote_rest/log_utils.py @@ -10,18 +10,19 @@ # LOG_DIR = "/var/log/attackmate_instances" # must exists and has write permissions # List of logger names to add instance-specific handlers to -TARGET_LOGGER_NAMES = ['playbook', 'output'] # json not needed +TARGET_LOGGER_NAMES = ['playbook', 'output', 'json'] # Create formatter for the instance files instance_log_formatter = logging.Formatter( '%(asctime)s %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) +json_log_formatter = logging.Formatter('%(message)s') @contextmanager def instance_logging(instance_id: str, log_level: int = logging.INFO): - """ + """cd Context manager to temporarily add a file handler for a specific instance to the target AttackMate loggers. """ @@ -36,6 +37,7 @@ def instance_logging(instance_id: str, log_level: int = logging.INFO): timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') instance_output_log_file = os.path.join(LOG_DIR, f"{timestamp}_{instance_id}_output.log") instance_attackmate_log_file = os.path.join(LOG_DIR, f"{timestamp}_{instance_id}_attackmate.log") + instance_json_log_file = os.path.join(LOG_DIR, f"{timestamp}_{instance_id}_attackmate.json") # instance-specific file handler # 'a' to append within the same request if multiple logs occur @@ -48,21 +50,32 @@ def instance_logging(instance_id: str, log_level: int = logging.INFO): attackmate_file_handler.setFormatter(instance_log_formatter) attackmate_file_handler.setLevel(log_level) + attackmate_json_handler = logging.FileHandler(instance_json_log_file, mode='a') + attackmate_json_handler.setFormatter(json_log_formatter) + attackmate_json_handler.setLevel(log_level) + # Add the handler to the target loggers for logger_name in TARGET_LOGGER_NAMES: logger = logging.getLogger(logger_name) logger.setLevel(log_level) + logger.propagate = False if logger_name == 'playbook': logger.addHandler(attackmate_file_handler) handlers.append(attackmate_file_handler) # remove later finally if logger_name == 'output': logger.addHandler(output_file_handler) handlers.append(output_file_handler) # remove later in finally + if logger_name == 'json': + logger.addHandler(attackmate_json_handler) + handlers.append(attackmate_json_handler) # remove later in finally logging.info( (f"Added instance log handlers for '{instance_id}' to logger '{logger_name}' -> " - f"{instance_output_log_file} and {instance_attackmate_log_file}") - ) - yield [instance_attackmate_log_file, instance_output_log_file] # 'with' block executes here + f"{instance_output_log_file} and {instance_attackmate_log_file} and {instance_json_log_file}.")) + yield [ + instance_attackmate_log_file, + instance_output_log_file, + instance_json_log_file + ] # 'with' block executes here and uses these paths except Exception as e: logging.error(f"Error setting up instance logging for '{instance_id}': {e}", exc_info=True) @@ -71,10 +84,11 @@ def instance_logging(instance_id: str, log_level: int = logging.INFO): finally: logging.info(f"Removing instance log handlers for '{instance_id}'...") for handler in handlers: - try: - for logger_name in TARGET_LOGGER_NAMES: - logger = logging.getLogger(logger_name) + for logger_name in TARGET_LOGGER_NAMES: + logger = logging.getLogger(logger_name) + if handler in logger.handlers: logger.removeHandler(handler) + try: handler.close() except Exception as e: logging.error( From d14a3e195466fdffbcaae1a8e53079fb14d8eb5b Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 15 Jul 2025 15:48:22 +0200 Subject: [PATCH 33/49] add json to remote logging --- remote_rest/routers/playbooks.py | 10 +++++++--- remote_rest/schemas.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 4f31bf08..1c0403c3 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -40,7 +40,7 @@ def read_log_file(log_path: Optional[str]) -> Optional[str]: async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type='application/yaml'), debug: bool = Query( False, - description="Enable debug level logging for this request's instance log." + description="Enable debug logging for this request's instance log." ), current_user: str = Depends(get_current_user), x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME)): @@ -52,7 +52,7 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' instance_id = str(uuid.uuid4()) log_level = logging.DEBUG if debug else logging.INFO with instance_logging(instance_id, log_level) as log_files: - attackmate_log_path, output_log_path = log_files + attackmate_log_path, output_log_path, json_log_path = log_files try: playbook_dict = yaml.safe_load(playbook_yaml) if not playbook_dict: @@ -65,6 +65,7 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' logger.info(f"Transient playbook execution finished. return code {return_code}") attackmate_log = read_log_file(attackmate_log_path) output_log = read_log_file(output_log_path) + json_log = read_log_file(json_log_path) except (yaml.YAMLError, ValidationError, ValueError) as e: logger.error(f"Playbook validation/parsing error: {e}") raise HTTPException(status_code=422, detail=f"Invalid playbook YAML: {e}") @@ -87,6 +88,7 @@ async def execute_playbook_from_yaml(playbook_yaml: str = Body(..., media_type=' instance_id=instance_id, attackmate_log=attackmate_log, output_log=output_log, + json_log=json_log, current_token=x_auth_token ) @@ -140,7 +142,7 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, instance_id = str(uuid.uuid4()) log_level = logging.DEBUG if debug else logging.INFO with instance_logging(instance_id, log_level) as log_files: - attackmate_log_path, output_log_path = log_files + attackmate_log_path, output_log_path, json_log_path = log_files try: logger.info(f"Parsing playbook from: {full_path}") playbook = parse_playbook(full_path, logger) @@ -151,6 +153,7 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, logger.info(f"Transient playbook execution finished. RC: {return_code}") attackmate_log = read_log_file(attackmate_log_path) output_log = read_log_file(output_log_path) + json_log = read_log_file(json_log_path) except (ValidationError, ValueError) as e: logger.error(f"Playbook validation error from file '{full_path}': {e}") raise HTTPException( @@ -174,5 +177,6 @@ async def execute_playbook_from_file(request_body: PlaybookFileRequest, instance_id=instance_id, attackmate_log=attackmate_log, output_log=output_log, + json_log=json_log, current_token=x_auth_token ) diff --git a/remote_rest/schemas.py b/remote_rest/schemas.py index 5dd18c6e..b1662098 100644 --- a/remote_rest/schemas.py +++ b/remote_rest/schemas.py @@ -28,6 +28,7 @@ class PlaybookResponseModel(BaseModel): instance_id: Optional[str] = None attackmate_log: Optional[str] = Field(None, description='Content of the attackmate.log for this run.') output_log: Optional[str] = Field(None, description='Content of the output.log for this run.') + json_log: Optional[str] = Field(None, description='Content of the attackmate.json for this run.') current_token: Optional[str] = Field(None, description='Renewed auth token for subsequent requests.') From b95a520a5a1dba5e72f8605a9b9216d6d8afad0f Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 15 Jul 2025 15:49:10 +0200 Subject: [PATCH 34/49] avoid duplicating stream handler --- src/attackmate/logging_setup.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/attackmate/logging_setup.py b/src/attackmate/logging_setup.py index 8e0fd549..3283520e 100644 --- a/src/attackmate/logging_setup.py +++ b/src/attackmate/logging_setup.py @@ -1,6 +1,7 @@ # logging_setup.py import logging +import sys from colorlog import ColoredFormatter @@ -35,16 +36,22 @@ def initialize_logger(debug: bool, append_logs: bool): playbook_logger.setLevel(logging.INFO) # output to console - console_handler = logging.StreamHandler() - LOGFORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s' '| %(log_color)s%(message)s%(reset)s' - formatter = ColoredFormatter(LOGFORMAT, datefmt='%Y-%m-%d %H:%M:%S') - console_handler.setFormatter(formatter) + if not any( + isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout + for handler in playbook_logger.handlers + ): + console_handler = logging.StreamHandler(sys.stdout) # Explicitly target stdout + LOGFORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s' '| %(log_color)s%(message)s%(reset)s' + formatter = ColoredFormatter(LOGFORMAT, datefmt='%Y-%m-%d %H:%M:%S') + console_handler.setFormatter(formatter) + playbook_logger.addHandler(console_handler) # plain text output - playbook_logger.addHandler(console_handler) - formatter = logging.Formatter('%(asctime)s %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') - file_handler = create_file_handler('attackmate.log', append_logs, formatter) + + formatter2 = logging.Formatter('%(asctime)s %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + file_handler = create_file_handler('attackmate.log', append_logs, formatter2) playbook_logger.addHandler(file_handler) + playbook_logger.propagate = False return playbook_logger From 0037313114a4ec46fd3a3ee2f947c38e8ebe1686 Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 17 Jul 2025 11:43:15 +0200 Subject: [PATCH 35/49] move api logging setup out of mein --- remote_rest/main.py | 19 ++++++++++--------- src/attackmate/logging_setup.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/remote_rest/main.py b/remote_rest/main.py index c850b6ac..567c3b29 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -1,4 +1,3 @@ -import logging from contextlib import asynccontextmanager import sys from typing import AsyncGenerator @@ -11,7 +10,8 @@ from src.attackmate.attackmate import AttackMate from src.attackmate.logging_setup import (initialize_json_logger, initialize_logger, - initialize_output_logger) + initialize_output_logger, + initialize_api_logger) from src.attackmate.playbook_parser import parse_config import remote_rest.state as state @@ -22,16 +22,14 @@ CERT_DIR = os.path.dirname(os.path.abspath(__file__)) -KEY_FILE = os.path.join(CERT_DIR, "key.pem") -CERT_FILE = os.path.join(CERT_DIR, "cert.pem") +KEY_FILE = os.path.join(CERT_DIR, 'key.pem') +CERT_FILE = os.path.join(CERT_DIR, 'cert.pem') # Logging initialize_logger(debug=True, append_logs=False) initialize_output_logger(debug=True, append_logs=False) initialize_json_logger(json=True, append_logs=False) -logger = logging.getLogger('attackmate_api') # specific logger for the API -# TODO make this configurable via request -logger.setLevel(logging.DEBUG) +logger = initialize_api_logger(debug=True, append_logs=False) @asynccontextmanager @@ -105,7 +103,10 @@ async def generic_exception_handler(request: Request, exc: Exception): status_code=400, # client-side error pattern content={ 'detail': 'Command execution led to termination request', - 'error_message': f"SystemExit triggered (likely due to error condition like 'exit_on_error' or 'error_if'). Exit code: {exc.code}", + 'error_message': ( + f"SystemExit triggered (likely due to error condition like 'exit_on_error'). " + f"Exit code: {exc.code}" + ), 'instance_id': None }, ) @@ -163,6 +164,6 @@ async def root(): host='0.0.0.0', port=8443, reload=False, - log_config=None, + ssl_keyfile=KEY_FILE, ssl_certfile=CERT_FILE) diff --git a/src/attackmate/logging_setup.py b/src/attackmate/logging_setup.py index 3283520e..29f0e9b3 100644 --- a/src/attackmate/logging_setup.py +++ b/src/attackmate/logging_setup.py @@ -66,3 +66,31 @@ def initialize_json_logger(json: bool, append_logs: bool): json_logger.addHandler(file_handler) return json_logger + +def initialize_api_logger(debug: bool, append_logs: bool): + api_logger = logging.getLogger('attackmate_api') + if debug: + api_logger.setLevel(logging.DEBUG) + else: + api_logger.setLevel(logging.INFO) + + # Console handler for API logs + if not any( + isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout + for handler in api_logger.handlers + ): + console_handler = logging.StreamHandler(sys.stdout) + LOGFORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s' '| API | %(log_color)s%(message)s%(reset)s' + formatter = ColoredFormatter(LOGFORMAT, datefmt='%Y-%m-%d %H:%M:%S') + console_handler.setFormatter(formatter) + api_logger.addHandler(console_handler) + + # File handler for API logs ? + # api_file_formatter = logging.Formatter('%(asctime)s %(levelname)s [API] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + # api_file_handler = create_file_handler('attackmate_api.log', append_logs, api_file_formatter) + # api_logger.addHandler(api_file_handler) + + # Prevent propagation to avoid duplicate logs if root logger also has handlers + api_logger.propagate = False + + return api_logger From 5269124aa7a0314c423acfc840688f6bbb03da19 Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 17 Jul 2025 14:50:07 +0200 Subject: [PATCH 36/49] add dependencies --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 72349a56..4bc60793 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,12 @@ dependencies = [ "vncdotool", "pytest-mock", "fastapi", - "playwright" + "playwright", + "argon2-cffi", + "uvicorn", + "dotenv", + "passlib", + "python-multipart" ] dynamic = ["version"] From 8c3b1961d773c3d67b64763de576fd0f78e6d67b Mon Sep 17 00:00:00 2001 From: kali Date: Thu, 17 Jul 2025 15:00:26 +0200 Subject: [PATCH 37/49] single endpoint for commands --- remote_rest/routers/commands.py | 215 ++++++++++++++++------------- src/attackmate/remote_client.py | 8 +- src/attackmate/schemas/loop.py | 3 - src/attackmate/schemas/playbook.py | 49 ++++++- src/attackmate/schemas/remote.py | 4 +- 5 files changed, 173 insertions(+), 106 deletions(-) diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 9de762d2..4c883188 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -1,16 +1,14 @@ import logging from typing import Optional - +from pydantic import BaseModel from attackmate.attackmate import AttackMate + from attackmate.schemas.base import BaseCommand from fastapi import APIRouter, Depends, Header, HTTPException +from attackmate.schemas.remote import RemoteCommand from src.attackmate.execexception import ExecException from src.attackmate.result import Result as AttackMateResult -from src.attackmate.schemas.debug import DebugCommand -from src.attackmate.schemas.setvar import SetVarCommand -from src.attackmate.schemas.shell import ShellCommand -from src.attackmate.schemas.sleep import SleepCommand -from src.attackmate.schemas.tempfile import TempfileCommand + from remote_rest.auth_utils import API_KEY_HEADER_NAME, get_current_user from remote_rest.schemas import CommandResultModel, ExecutionResponseModel @@ -18,13 +16,15 @@ from ..state import get_persistent_instance -# ADD IMPORTS FOR OTHER COMMAND PYDANTIC SCHEMAS HERE - router = APIRouter(prefix='/command', tags=['Commands']) logger = logging.getLogger(__name__) +class CommandRequest(BaseModel): + command: RemoteCommand # type: ignore + + async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: """Runs a command on a given AttackMate instance.""" try: @@ -41,20 +41,17 @@ async def run_command_on_instance(instance: AttackMate, command_data: BaseComman raise HTTPException(status_code=500, detail=f"Internal server error during command execution: {e}") -# Command Endpoints -@router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_shell_command( - command: ShellCommand, +@router.post('/execute', response_model=ExecutionResponseModel) +async def execute_unified_command( + command_request: CommandRequest, instance: AttackMate = Depends(get_persistent_instance), current_user: str = Depends(get_current_user), x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) ): - """Executes a shell command on the specified AttackMate instance.""" - attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands + # command_request.command will be the correct Pydantic type based on doscriminated union in RemoteCommand + attackmate_result = await run_command_on_instance(instance, command_request.command) - # response result_model = CommandResultModel( - # Success if RC 0 or None (background) success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), stdout=attackmate_result.stdout, returncode=attackmate_result.returncode @@ -67,84 +64,110 @@ async def execute_shell_command( current_token=x_auth_token ) - -@router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_sleep_command( - command: SleepCommand, - instance: AttackMate = Depends(get_persistent_instance), - current_user: str = Depends(get_current_user), - x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -): - """Executes a sleep command on the specified AttackMate instance.""" - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel( - success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel( - result=result_model, - state=state_model, - instance_id='default-context', - current_token=x_auth_token - ) - - -@router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_debug_command( - command: DebugCommand, - instance: AttackMate = Depends(get_persistent_instance), - current_user: str = Depends(get_current_user), - x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -): - """Executes a debug command on the specified AttackMate instance.""" - attackmate_result = await run_command_on_instance(instance, command) - # Debug command might trigger SystemExit if command.exit is True - result_model = CommandResultModel( - success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel( - result=result_model, - state=state_model, - instance_id='default-context', - current_token=x_auth_token - ) - - -@router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_setvar_command( - command: SetVarCommand, - instance: AttackMate = Depends(get_persistent_instance), - current_user: str = Depends(get_current_user), - x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -): - """Executes a setvar command on the specified AttackMate instance.""" - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel( - success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel( - result=result_model, - state=state_model, - instance_id='default-context', - current_token=x_auth_token - ) - - -@router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) -async def execute_mktemp_command( - command: TempfileCommand, - instance: AttackMate = Depends(get_persistent_instance), - current_user: str = Depends(get_current_user), - x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -): - """Executes an mktemp command (create temp file/dir) on the specified instance.""" - attackmate_result = await run_command_on_instance(instance, command) - result_model = CommandResultModel( - success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) - state_model = varstore_to_state_model(instance.varstore) - return ExecutionResponseModel( - result=result_model, - state=state_model, - instance_id='default-context', - current_token=x_auth_token - ) +# # Command Endpoints +# @router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) +# async def execute_shell_command( +# command: ShellCommand, +# instance: AttackMate = Depends(get_persistent_instance), +# current_user: str = Depends(get_current_user), +# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) +# ): +# """Executes a shell command on the specified AttackMate instance.""" +# attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands + +# # response +# result_model = CommandResultModel( +# # Success if RC 0 or None (background) +# success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), +# stdout=attackmate_result.stdout, +# returncode=attackmate_result.returncode +# ) +# state_model = varstore_to_state_model(instance.varstore) +# return ExecutionResponseModel( +# result=result_model, +# state=state_model, +# instance_id='default-context', +# current_token=x_auth_token +# ) + + +# @router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) +# async def execute_sleep_command( +# command: SleepCommand, +# instance: AttackMate = Depends(get_persistent_instance), +# current_user: str = Depends(get_current_user), +# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) +# ): +# """Executes a sleep command on the specified AttackMate instance.""" +# attackmate_result = await run_command_on_instance(instance, command) +# result_model = CommandResultModel( +# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) +# state_model = varstore_to_state_model(instance.varstore) +# return ExecutionResponseModel( +# result=result_model, +# state=state_model, +# instance_id='default-context', +# current_token=x_auth_token +# ) + + +# @router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) +# async def execute_debug_command( +# command: DebugCommand, +# instance: AttackMate = Depends(get_persistent_instance), +# current_user: str = Depends(get_current_user), +# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) +# ): +# """Executes a debug command on the specified AttackMate instance.""" +# attackmate_result = await run_command_on_instance(instance, command) +# # Debug command might trigger SystemExit if command.exit is True +# result_model = CommandResultModel( +# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) +# state_model = varstore_to_state_model(instance.varstore) +# return ExecutionResponseModel( +# result=result_model, +# state=state_model, +# instance_id='default-context', +# current_token=x_auth_token +# ) + + +# @router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) +# async def execute_setvar_command( +# command: SetVarCommand, +# instance: AttackMate = Depends(get_persistent_instance), +# current_user: str = Depends(get_current_user), +# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) +# ): +# """Executes a setvar command on the specified AttackMate instance.""" +# attackmate_result = await run_command_on_instance(instance, command) +# result_model = CommandResultModel( +# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) +# state_model = varstore_to_state_model(instance.varstore) +# return ExecutionResponseModel( +# result=result_model, +# state=state_model, +# instance_id='default-context', +# current_token=x_auth_token +# ) + + +# @router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) +# async def execute_mktemp_command( +# command: TempfileCommand, +# instance: AttackMate = Depends(get_persistent_instance), +# current_user: str = Depends(get_current_user), +# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) +# ): +# """Executes an mktemp command (create temp file/dir) on the specified instance.""" +# attackmate_result = await run_command_on_instance(instance, command) +# result_model = CommandResultModel( +# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) +# state_model = varstore_to_state_model(instance.varstore) +# return ExecutionResponseModel( +# result=result_model, +# state=state_model, +# instance_id='default-context', +# current_token=x_auth_token +# ) # Add other command endpoints here diff --git a/src/attackmate/remote_client.py b/src/attackmate/remote_client.py index 903d4724..fe882ada 100644 --- a/src/attackmate/remote_client.py +++ b/src/attackmate/remote_client.py @@ -179,16 +179,18 @@ def execute_remote_command( debug: bool = False ) -> Optional[Dict[str, Any]]: # get the correct enpoint - command_type_for_path = command_pydantic_model.type.replace('_', '-') # API path has hyphens - endpoint = f"command/{command_type_for_path}" + endpoint = "command/execute" # Convert Pydantic model to dict for JSON body # handle None values for optional fields (exclude_none=True) command_body_dict = command_pydantic_model.model_dump(exclude_none=True) + request_payload = { + "command": command_body_dict + } return self._make_request( method='POST', endpoint=endpoint, - json_data=command_body_dict, + json_data=request_payload, params={'debug': True} if debug else None ) diff --git a/src/attackmate/schemas/loop.py b/src/attackmate/schemas/loop.py index e626b9ac..31b51b53 100644 --- a/src/attackmate/schemas/loop.py +++ b/src/attackmate/schemas/loop.py @@ -1,6 +1,5 @@ from attackmate.schemas.base import BaseCommand from pydantic import field_validator -from typing import Literal from attackmate.command import CommandRegistry from typing import Literal, Union, Optional, List from .sleep import SleepCommand @@ -26,7 +25,6 @@ SliverGenerateCommand, ) from .ssh import SSHCommand, SFTPCommand -from .remote import AttackMateRemoteCommand from .http import WebServCommand, HttpClientCommand from .father import FatherCommand from .tempfile import TempfileCommand @@ -37,7 +35,6 @@ Commands = List[ Union[ - AttackMateRemoteCommand, BrowserCommand, ShellCommand, MsfModuleCommand, diff --git a/src/attackmate/schemas/playbook.py b/src/attackmate/schemas/playbook.py index 717ce8ec..6b66d628 100644 --- a/src/attackmate/schemas/playbook.py +++ b/src/attackmate/schemas/playbook.py @@ -1,6 +1,7 @@ -from typing import List, Optional, Dict, Union +from typing import Annotated, List, Optional, Dict, Union + from .base import StrInt -from pydantic import BaseModel +from pydantic import BaseModel, Field from .sleep import SleepCommand from .shell import ShellCommand from .setvar import SetVarCommand @@ -73,6 +74,50 @@ Commands = List[Command] +SliverSessionCommands = Annotated[Union[ + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand], Field(discriminator='cmd')] + + +SliverCommands = Annotated[Union[ + SliverHttpsListenerCommand, + SliverGenerateCommand], Field(discriminator='cmd')] + +RemoteCommand = Annotated[ + Union[ + SliverSessionCommands, + SliverCommands, + BrowserCommand, + ShellCommand, + MsfModuleCommand, + MsfSessionCommand, + MsfPayloadCommand, + SleepCommand, + SSHCommand, + FatherCommand, + SFTPCommand, + DebugCommand, + SetVarCommand, + RegExCommand, + VncCommand, + TempfileCommand, + IncludeCommand, + WebServCommand, + HttpClientCommand, + JsonCommand, + ], + Field(discriminator='type'), +] + class Playbook(BaseModel): vars: Optional[Dict[str, List[StrInt] | StrInt]] = None diff --git a/src/attackmate/schemas/remote.py b/src/attackmate/schemas/remote.py index 5ae57a71..4df5f202 100644 --- a/src/attackmate/schemas/remote.py +++ b/src/attackmate/schemas/remote.py @@ -1,5 +1,5 @@ -from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator -from typing import Literal, Optional, Dict, Any, List, Union, TypeAlias +from pydantic import model_validator +from typing import Literal, Optional, Union, TypeAlias from .base import BaseCommand from attackmate.command import CommandRegistry From 0c305f0af69f4bbc0a0e412ddfc5415a78a3f640 Mon Sep 17 00:00:00 2001 From: kali Date: Sat, 19 Jul 2025 12:26:44 +0200 Subject: [PATCH 38/49] improve logging setup --- src/attackmate/logging_setup.py | 83 +++++++++++++++++---------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/src/attackmate/logging_setup.py b/src/attackmate/logging_setup.py index 29f0e9b3..0434de7f 100644 --- a/src/attackmate/logging_setup.py +++ b/src/attackmate/logging_setup.py @@ -4,51 +4,38 @@ import sys from colorlog import ColoredFormatter +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' +OUTPUT_LOG_FILE = 'output.log' +PLAYBOOK_LOG_FILE = 'attackmate.log' +JSON_LOG_FILE = 'attackmate.json' -def create_file_handler( - file_name: str, append_logs: bool, formatter: logging.Formatter -) -> logging.FileHandler: - mode = 'a' if append_logs else 'w' - file_handler = logging.FileHandler(file_name, mode=mode) - file_handler.setFormatter(formatter) - - return file_handler +PLAYBOOK_CONSOLE_FORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s | %(log_color)s%(message)s%(reset)s' +API_CONSOLE_FORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s | API | %(log_color)s%(message)s%(reset)s' +DEFAULT_FILE_FORMAT = '%(asctime)s %(levelname)s - %(message)s' +OUTPUT_FILE_FORMAT = '--- %(asctime)s %(levelname)s: ---\n\n%(message)s' def initialize_output_logger(debug: bool, append_logs: bool): output_logger = logging.getLogger('output') - if debug: - output_logger.setLevel(logging.DEBUG) - else: - output_logger.setLevel(logging.INFO) - formatter = logging.Formatter( - '--- %(asctime)s %(levelname)s: ---\n\n%(message)s', datefmt='%Y-%m-%d %H:%M:%S' - ) - file_handler = create_file_handler('output.log', append_logs, formatter) + output_logger.setLevel(logging.DEBUG if debug else logging.INFO) + formatter = logging.Formatter(OUTPUT_FILE_FORMAT, datefmt=DATE_FORMAT) + file_handler = create_file_handler(OUTPUT_LOG_FILE, append_logs, formatter) output_logger.addHandler(file_handler) def initialize_logger(debug: bool, append_logs: bool): playbook_logger = logging.getLogger('playbook') - if debug: - playbook_logger.setLevel(logging.DEBUG) - else: - playbook_logger.setLevel(logging.INFO) + playbook_logger.setLevel(logging.DEBUG if debug else logging.INFO) # output to console - if not any( - isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout - for handler in playbook_logger.handlers - ): - console_handler = logging.StreamHandler(sys.stdout) # Explicitly target stdout - LOGFORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s' '| %(log_color)s%(message)s%(reset)s' - formatter = ColoredFormatter(LOGFORMAT, datefmt='%Y-%m-%d %H:%M:%S') + if not has_stdout_handler(playbook_logger): + console_handler = logging.StreamHandler(sys.stdout) # Explicitly target stdout + formatter = ColoredFormatter(PLAYBOOK_CONSOLE_FORMAT, datefmt=DATE_FORMAT) console_handler.setFormatter(formatter) playbook_logger.addHandler(console_handler) # plain text output - - formatter2 = logging.Formatter('%(asctime)s %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + formatter2 = logging.Formatter(DEFAULT_FILE_FORMAT, datefmt=DATE_FORMAT) file_handler = create_file_handler('attackmate.log', append_logs, formatter2) playbook_logger.addHandler(file_handler) playbook_logger.propagate = False @@ -62,31 +49,25 @@ def initialize_json_logger(json: bool, append_logs: bool): json_logger = logging.getLogger('json') json_logger.setLevel(logging.DEBUG) formatter = logging.Formatter('%(message)s') - file_handler = create_file_handler('attackmate.json', append_logs, formatter) + file_handler = create_file_handler(JSON_LOG_FILE, append_logs, formatter) json_logger.addHandler(file_handler) return json_logger + def initialize_api_logger(debug: bool, append_logs: bool): api_logger = logging.getLogger('attackmate_api') - if debug: - api_logger.setLevel(logging.DEBUG) - else: - api_logger.setLevel(logging.INFO) + api_logger.setLevel(logging.DEBUG if debug else logging.INFO) # Console handler for API logs - if not any( - isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout - for handler in api_logger.handlers - ): + if not has_stdout_handler(api_logger): console_handler = logging.StreamHandler(sys.stdout) - LOGFORMAT = ' %(asctime)s %(log_color)s%(levelname)-8s%(reset)s' '| API | %(log_color)s%(message)s%(reset)s' - formatter = ColoredFormatter(LOGFORMAT, datefmt='%Y-%m-%d %H:%M:%S') + formatter = ColoredFormatter(API_CONSOLE_FORMAT, datefmt=DATE_FORMAT) console_handler.setFormatter(formatter) api_logger.addHandler(console_handler) # File handler for API logs ? - # api_file_formatter = logging.Formatter('%(asctime)s %(levelname)s [API] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + # api_file_formatter = logging.Formatter(API_CONSOLE_FORMAT, datefmt=DATE_FORMAT) # api_file_handler = create_file_handler('attackmate_api.log', append_logs, api_file_formatter) # api_logger.addHandler(api_file_handler) @@ -94,3 +75,23 @@ def initialize_api_logger(debug: bool, append_logs: bool): api_logger.propagate = False return api_logger + + +def create_file_handler( + file_name: str, append_logs: bool, formatter: logging.Formatter +) -> logging.FileHandler: + mode = 'a' if append_logs else 'w' + file_handler = logging.FileHandler(file_name, mode=mode) + file_handler.setFormatter(formatter) + return file_handler + + +def has_stdout_handler(logger: logging.Logger) -> bool: + """ + Checks if a logger already has a StreamHandler directed to stdout. + + """ + return any( + isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout + for handler in logger.handlers + ) From 51c48f47a8edea558e048e76d014edceea0c7736 Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 21 Jul 2025 12:52:07 +0200 Subject: [PATCH 39/49] use global variable for log file name --- src/attackmate/logging_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attackmate/logging_setup.py b/src/attackmate/logging_setup.py index 0434de7f..6e9f3f13 100644 --- a/src/attackmate/logging_setup.py +++ b/src/attackmate/logging_setup.py @@ -36,7 +36,7 @@ def initialize_logger(debug: bool, append_logs: bool): # plain text output formatter2 = logging.Formatter(DEFAULT_FILE_FORMAT, datefmt=DATE_FORMAT) - file_handler = create_file_handler('attackmate.log', append_logs, formatter2) + file_handler = create_file_handler(PLAYBOOK_LOG_FILE, append_logs, formatter2) playbook_logger.addHandler(file_handler) playbook_logger.propagate = False From 9cd22978e70c324f082dae0e8851323f788d4e0b Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 21 Jul 2025 12:52:58 +0200 Subject: [PATCH 40/49] remove single command endpoints --- remote_rest/routers/commands.py | 108 -------------------------------- 1 file changed, 108 deletions(-) diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 4c883188..16f69707 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -63,111 +63,3 @@ async def execute_unified_command( instance_id='default-context', current_token=x_auth_token ) - -# # Command Endpoints -# @router.post('/shell', response_model=ExecutionResponseModel, tags=['Commands']) -# async def execute_shell_command( -# command: ShellCommand, -# instance: AttackMate = Depends(get_persistent_instance), -# current_user: str = Depends(get_current_user), -# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -# ): -# """Executes a shell command on the specified AttackMate instance.""" -# attackmate_result = await run_command_on_instance(instance, command) # WHat about backgorund commands - -# # response -# result_model = CommandResultModel( -# # Success if RC 0 or None (background) -# success=(attackmate_result.returncode == 0 if attackmate_result.returncode is not None else True), -# stdout=attackmate_result.stdout, -# returncode=attackmate_result.returncode -# ) -# state_model = varstore_to_state_model(instance.varstore) -# return ExecutionResponseModel( -# result=result_model, -# state=state_model, -# instance_id='default-context', -# current_token=x_auth_token -# ) - - -# @router.post('/sleep', response_model=ExecutionResponseModel, tags=['Commands']) -# async def execute_sleep_command( -# command: SleepCommand, -# instance: AttackMate = Depends(get_persistent_instance), -# current_user: str = Depends(get_current_user), -# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -# ): -# """Executes a sleep command on the specified AttackMate instance.""" -# attackmate_result = await run_command_on_instance(instance, command) -# result_model = CommandResultModel( -# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) -# state_model = varstore_to_state_model(instance.varstore) -# return ExecutionResponseModel( -# result=result_model, -# state=state_model, -# instance_id='default-context', -# current_token=x_auth_token -# ) - - -# @router.post('/debug', response_model=ExecutionResponseModel, tags=['Commands']) -# async def execute_debug_command( -# command: DebugCommand, -# instance: AttackMate = Depends(get_persistent_instance), -# current_user: str = Depends(get_current_user), -# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -# ): -# """Executes a debug command on the specified AttackMate instance.""" -# attackmate_result = await run_command_on_instance(instance, command) -# # Debug command might trigger SystemExit if command.exit is True -# result_model = CommandResultModel( -# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) -# state_model = varstore_to_state_model(instance.varstore) -# return ExecutionResponseModel( -# result=result_model, -# state=state_model, -# instance_id='default-context', -# current_token=x_auth_token -# ) - - -# @router.post('/setvar', response_model=ExecutionResponseModel, tags=['Commands']) -# async def execute_setvar_command( -# command: SetVarCommand, -# instance: AttackMate = Depends(get_persistent_instance), -# current_user: str = Depends(get_current_user), -# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -# ): -# """Executes a setvar command on the specified AttackMate instance.""" -# attackmate_result = await run_command_on_instance(instance, command) -# result_model = CommandResultModel( -# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) -# state_model = varstore_to_state_model(instance.varstore) -# return ExecutionResponseModel( -# result=result_model, -# state=state_model, -# instance_id='default-context', -# current_token=x_auth_token -# ) - - -# @router.post('/mktemp', response_model=ExecutionResponseModel, tags=['Commands']) -# async def execute_mktemp_command( -# command: TempfileCommand, -# instance: AttackMate = Depends(get_persistent_instance), -# current_user: str = Depends(get_current_user), -# x_auth_token: Optional[str] = Header(None, alias=API_KEY_HEADER_NAME) -# ): -# """Executes an mktemp command (create temp file/dir) on the specified instance.""" -# attackmate_result = await run_command_on_instance(instance, command) -# result_model = CommandResultModel( -# success=True, stdout=attackmate_result.stdout, returncode=attackmate_result.returncode) -# state_model = varstore_to_state_model(instance.varstore) -# return ExecutionResponseModel( -# result=result_model, -# state=state_model, -# instance_id='default-context', -# current_token=x_auth_token -# ) -# Add other command endpoints here From bf4b0b1bbeb69d4f2c83aa85a905a971b3f4a1fe Mon Sep 17 00:00:00 2001 From: kali Date: Mon, 21 Jul 2025 14:53:29 +0200 Subject: [PATCH 41/49] refactor command schema imports --- remote_rest/routers/commands.py | 4 +- remote_rest/routers/instances.py | 10 +- remote_rest/routers/playbooks.py | 2 +- src/attackmate/attackmate.py | 11 +- .../executors/common/loopexecutor.py | 8 +- src/attackmate/remote_client.py | 4 +- src/attackmate/schemas/command_subtypes.py | 83 ++++++++++++ src/attackmate/schemas/command_types.py | 14 ++ src/attackmate/schemas/loop.py | 15 ++- src/attackmate/schemas/playbook.py | 121 +----------------- src/attackmate/schemas/remote.py | 73 +---------- 11 files changed, 138 insertions(+), 207 deletions(-) create mode 100644 src/attackmate/schemas/command_subtypes.py create mode 100644 src/attackmate/schemas/command_types.py diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index 16f69707..e85d60fe 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -5,9 +5,9 @@ from attackmate.schemas.base import BaseCommand from fastapi import APIRouter, Depends, Header, HTTPException -from attackmate.schemas.remote import RemoteCommand from src.attackmate.execexception import ExecException from src.attackmate.result import Result as AttackMateResult +from src.attackmate.schemas.commands import Command from remote_rest.auth_utils import API_KEY_HEADER_NAME, get_current_user @@ -22,7 +22,7 @@ class CommandRequest(BaseModel): - command: RemoteCommand # type: ignore + command: Command async def run_command_on_instance(instance: AttackMate, command_data: BaseCommand) -> AttackMateResult: diff --git a/remote_rest/routers/instances.py b/remote_rest/routers/instances.py index c8b4c7ec..2f4d7b0d 100644 --- a/remote_rest/routers/instances.py +++ b/remote_rest/routers/instances.py @@ -14,10 +14,16 @@ @router.get('/{instance_id}/state', response_model=VariableStoreStateModel) -async def get_instance_state(instance: AttackMate = Depends(get_instance_by_id), current_user: str = Depends(get_current_user)): +async def get_instance_state( + instance: AttackMate = Depends(get_instance_by_id), + current_user: str = Depends(get_current_user) +): return varstore_to_state_model(instance.varstore) @router.get('/state', response_model=VariableStoreStateModel) -async def get_persistent_instance_state(instance: AttackMate = Depends(get_persistent_instance), current_user: str = Depends(get_current_user)): +async def get_persistent_instance_state( + instance: AttackMate = Depends(get_persistent_instance), + current_user: str = Depends(get_current_user) +): return varstore_to_state_model(instance.varstore) diff --git a/remote_rest/routers/playbooks.py b/remote_rest/routers/playbooks.py index 1c0403c3..8e75d14e 100644 --- a/remote_rest/routers/playbooks.py +++ b/remote_rest/routers/playbooks.py @@ -22,7 +22,7 @@ ALLOWED_PLAYBOOK_DIR = '/usr/local/share/attackmate/remote_playbooks/' # MUST EXIST -# helper tp read logfil +# helper t0 read logfile def read_log_file(log_path: Optional[str]) -> Optional[str]: if not log_path or not os.path.exists(log_path): return None diff --git a/src/attackmate/attackmate.py b/src/attackmate/attackmate.py index 6d90be5d..a6699165 100644 --- a/src/attackmate/attackmate.py +++ b/src/attackmate/attackmate.py @@ -11,14 +11,15 @@ import time from typing import Dict, Optional import logging -from attackmate.result import Result +from .result import Result import attackmate.executors as executors -from attackmate.schemas.config import CommandConfig, Config, MsfConfig, SliverConfig -from attackmate.schemas.playbook import Playbook, Commands, Command +from .schemas.config import CommandConfig, Config, MsfConfig, SliverConfig +from .schemas.playbook import Playbook +from attackmate.schemas.commands import Commands, Command from .variablestore import VariableStore from .processmanager import ProcessManager -from attackmate.executors.baseexecutor import BaseExecutor -from attackmate.executors.executor_factory import executor_factory +from .executors.baseexecutor import BaseExecutor +from .executors.executor_factory import executor_factory import asyncio diff --git a/src/attackmate/executors/common/loopexecutor.py b/src/attackmate/executors/common/loopexecutor.py index 1818e78d..7671a6fd 100644 --- a/src/attackmate/executors/common/loopexecutor.py +++ b/src/attackmate/executors/common/loopexecutor.py @@ -11,7 +11,7 @@ from attackmate.executors.features.conditional import Conditional from attackmate.result import Result from attackmate.schemas.loop import LoopCommand -from attackmate.schemas.playbook import Commands, Command +from attackmate.schemas.command_types import Commands, Command from attackmate.variablestore import VariableStore from attackmate.execexception import ExecException from attackmate.processmanager import ProcessManager @@ -54,7 +54,7 @@ def substitute_variables_in_command(self, command_obj, placeholders: dict): and command_obj.url = '$LOOP_ITEM', then it becomes 'https://example.com'. """ for attr_name, attr_val in vars(command_obj).items(): - if isinstance(attr_val, str) and "$" in attr_val: + if isinstance(attr_val, str) and '$' in attr_val: new_val = Template(attr_val).safe_substitute(placeholders) setattr(command_obj, attr_name, new_val) @@ -80,7 +80,7 @@ def loop_items(self, command: LoopCommand, varname: str, iterable: list[str]) -> 'LOOP_ITEM': x, **self.varstore.variables, } - + if self.break_condition_met(command, placeholders): return self.substitute_variables_in_command(template_cmd, placeholders) @@ -136,4 +136,4 @@ def _exec_cmd(self, command: LoopCommand) -> Result: # runfunc will replace global variables then self.execute_loop(command) self.logger.info('Loop execution complete') - return Result('', 0) \ No newline at end of file + return Result('', 0) diff --git a/src/attackmate/remote_client.py b/src/attackmate/remote_client.py index fe882ada..3f8be9e9 100644 --- a/src/attackmate/remote_client.py +++ b/src/attackmate/remote_client.py @@ -179,13 +179,13 @@ def execute_remote_command( debug: bool = False ) -> Optional[Dict[str, Any]]: # get the correct enpoint - endpoint = "command/execute" + endpoint = 'command/execute' # Convert Pydantic model to dict for JSON body # handle None values for optional fields (exclude_none=True) command_body_dict = command_pydantic_model.model_dump(exclude_none=True) request_payload = { - "command": command_body_dict + 'command': command_body_dict } return self._make_request( diff --git a/src/attackmate/schemas/command_subtypes.py b/src/attackmate/schemas/command_subtypes.py new file mode 100644 index 00000000..f523bae0 --- /dev/null +++ b/src/attackmate/schemas/command_subtypes.py @@ -0,0 +1,83 @@ +from __future__ import annotations +from typing import Annotated, TypeAlias, Union +from pydantic import Field +# Core Commands +from .sleep import SleepCommand +from .shell import ShellCommand +from .setvar import SetVarCommand +from .include import IncludeCommand +from .loop import LoopCommand +from .http import WebServCommand, HttpClientCommand +from .father import FatherCommand +from .tempfile import TempfileCommand +from .debug import DebugCommand +from .regex import RegExCommand +from .vnc import VncCommand +from .json import JsonCommand +from .browser import BrowserCommand +from .ssh import SSHCommand, SFTPCommand +# Metasploit Commands +from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand +# Sliver Commands +from .sliver import ( + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand, + SliverHttpsListenerCommand, + SliverGenerateCommand, +) + +SliverSessionCommands: TypeAlias = Annotated[Union[ + SliverSessionCDCommand, + SliverSessionLSCommand, + SliverSessionNETSTATCommand, + SliverSessionEXECCommand, + SliverSessionMKDIRCommand, + SliverSessionSimpleCommand, + SliverSessionDOWNLOADCommand, + SliverSessionUPLOADCommand, + SliverSessionPROCDUMPCommand, + SliverSessionRMCommand, + SliverSessionTERMINATECommand], Field(discriminator='cmd')] + + +SliverCommands: TypeAlias = Annotated[Union[ + SliverHttpsListenerCommand, + SliverGenerateCommand], Field(discriminator='cmd')] + + +# This excludes the AttackMateRemoteCommand type +RemotelyExecutableCommand: TypeAlias = Annotated[ + Union[ + SliverSessionCommands, + SliverCommands, + BrowserCommand, + ShellCommand, + MsfModuleCommand, + MsfSessionCommand, + MsfPayloadCommand, + SleepCommand, + SSHCommand, + FatherCommand, + SFTPCommand, + DebugCommand, + SetVarCommand, + RegExCommand, + TempfileCommand, + IncludeCommand, + LoopCommand, + WebServCommand, + HttpClientCommand, + JsonCommand, + VncCommand, + ], + Field(discriminator='type'), +] diff --git a/src/attackmate/schemas/command_types.py b/src/attackmate/schemas/command_types.py new file mode 100644 index 00000000..0e1823c1 --- /dev/null +++ b/src/attackmate/schemas/command_types.py @@ -0,0 +1,14 @@ + + +from typing import List, TypeAlias, Union +from attackmate.schemas.command_subtypes import RemotelyExecutableCommand +from attackmate.schemas.remote import AttackMateRemoteCommand + + +Command: TypeAlias = Union[ + RemotelyExecutableCommand, + AttackMateRemoteCommand + ] + + +Commands: TypeAlias = List[Command] diff --git a/src/attackmate/schemas/loop.py b/src/attackmate/schemas/loop.py index 31b51b53..1d8c0c77 100644 --- a/src/attackmate/schemas/loop.py +++ b/src/attackmate/schemas/loop.py @@ -7,6 +7,14 @@ from .vnc import VncCommand from .setvar import SetVarCommand from .include import IncludeCommand +from .ssh import SSHCommand, SFTPCommand +from .http import WebServCommand, HttpClientCommand +from .father import FatherCommand +from .tempfile import TempfileCommand +from .debug import DebugCommand +from .regex import RegExCommand +from .browser import BrowserCommand + from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand from .sliver import ( @@ -24,13 +32,6 @@ SliverHttpsListenerCommand, SliverGenerateCommand, ) -from .ssh import SSHCommand, SFTPCommand -from .http import WebServCommand, HttpClientCommand -from .father import FatherCommand -from .tempfile import TempfileCommand -from .debug import DebugCommand -from .regex import RegExCommand -from .browser import BrowserCommand Commands = List[ diff --git a/src/attackmate/schemas/playbook.py b/src/attackmate/schemas/playbook.py index 6b66d628..74e6ba1a 100644 --- a/src/attackmate/schemas/playbook.py +++ b/src/attackmate/schemas/playbook.py @@ -1,122 +1,9 @@ -from typing import Annotated, List, Optional, Dict, Union +from __future__ import annotations +from typing import List, Optional, Dict from .base import StrInt -from pydantic import BaseModel, Field -from .sleep import SleepCommand -from .shell import ShellCommand -from .setvar import SetVarCommand -from .include import IncludeCommand -from .loop import LoopCommand -from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand - -from .sliver import ( - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand, - SliverHttpsListenerCommand, - SliverGenerateCommand, -) -from .ssh import SSHCommand, SFTPCommand -from .http import WebServCommand, HttpClientCommand -from .father import FatherCommand -from .tempfile import TempfileCommand -from .debug import DebugCommand -from .regex import RegExCommand -from .vnc import VncCommand -from .json import JsonCommand -from .browser import BrowserCommand -from .remote import AttackMateRemoteCommand - -Command = Union[ - BrowserCommand, - ShellCommand, - MsfModuleCommand, - MsfSessionCommand, - MsfPayloadCommand, - SleepCommand, - SSHCommand, - FatherCommand, - SFTPCommand, - DebugCommand, - SetVarCommand, - RegExCommand, - TempfileCommand, - IncludeCommand, - LoopCommand, - WebServCommand, - HttpClientCommand, - JsonCommand, - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand, - SliverHttpsListenerCommand, - SliverGenerateCommand, - VncCommand, - AttackMateRemoteCommand -] - - -Commands = List[Command] - -SliverSessionCommands = Annotated[Union[ - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand], Field(discriminator='cmd')] - - -SliverCommands = Annotated[Union[ - SliverHttpsListenerCommand, - SliverGenerateCommand], Field(discriminator='cmd')] - -RemoteCommand = Annotated[ - Union[ - SliverSessionCommands, - SliverCommands, - BrowserCommand, - ShellCommand, - MsfModuleCommand, - MsfSessionCommand, - MsfPayloadCommand, - SleepCommand, - SSHCommand, - FatherCommand, - SFTPCommand, - DebugCommand, - SetVarCommand, - RegExCommand, - VncCommand, - TempfileCommand, - IncludeCommand, - WebServCommand, - HttpClientCommand, - JsonCommand, - ], - Field(discriminator='type'), -] +from pydantic import BaseModel +from .commands import Commands class Playbook(BaseModel): diff --git a/src/attackmate/schemas/remote.py b/src/attackmate/schemas/remote.py index 4df5f202..c365f7c9 100644 --- a/src/attackmate/schemas/remote.py +++ b/src/attackmate/schemas/remote.py @@ -1,72 +1,11 @@ +from __future__ import annotations from pydantic import model_validator -from typing import Literal, Optional, Union, TypeAlias +from typing import Literal, Optional from .base import BaseCommand from attackmate.command import CommandRegistry -from .sleep import SleepCommand -from .shell import ShellCommand -from .vnc import VncCommand -from .setvar import SetVarCommand -from .include import IncludeCommand -from .metasploit import MsfModuleCommand, MsfSessionCommand, MsfPayloadCommand - -from .sliver import ( - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand, - SliverHttpsListenerCommand, - SliverGenerateCommand, -) -from .ssh import SSHCommand, SFTPCommand -from .http import WebServCommand, HttpClientCommand -from .father import FatherCommand -from .tempfile import TempfileCommand -from .debug import DebugCommand -from .regex import RegExCommand -from .browser import BrowserCommand - - -RemoteCommand: TypeAlias = Union[ - BrowserCommand, - ShellCommand, - MsfModuleCommand, - MsfSessionCommand, - MsfPayloadCommand, - SleepCommand, - SSHCommand, - FatherCommand, - SFTPCommand, - DebugCommand, - SetVarCommand, - RegExCommand, - VncCommand, - TempfileCommand, - IncludeCommand, - WebServCommand, - HttpClientCommand, - SliverSessionCDCommand, - SliverSessionLSCommand, - SliverSessionNETSTATCommand, - SliverSessionEXECCommand, - SliverSessionMKDIRCommand, - SliverSessionSimpleCommand, - SliverSessionDOWNLOADCommand, - SliverSessionUPLOADCommand, - SliverSessionPROCDUMPCommand, - SliverSessionRMCommand, - SliverSessionTERMINATECommand, - SliverHttpsListenerCommand, - SliverGenerateCommand, -] +from .command_subtypes import RemotelyExecutableCommand @CommandRegistry.register('remote') @@ -75,15 +14,15 @@ class AttackMateRemoteCommand(BaseCommand): type: Literal['remote'] cmd: Literal['execute_command', 'execute_playbook_yaml', 'execute_playbook_file'] server_url: str - cacert: str # configure this file path in some configs elsewhere? + cacert: str # TODO configure this file path in some configs elsewhere? user: str password: str playbook_yaml_content: Optional[str] = None playbook_file_path: Optional[str] = None - remote_command: Optional[RemoteCommand] = None + remote_command: Optional[RemotelyExecutableCommand] = None # Common command parameters (like background, only_if) from BaseCommand - # will be applied to the 'remote' command itself, not the remote_command directly -> note in docs + # will be applied to the command itself, not the remote_command executed on the remote instance @model_validator(mode='after') def check_remote_command(self) -> 'AttackMateRemoteCommand': From 4cd8529a507417b5db10de31fd20cfd812d4d150 Mon Sep 17 00:00:00 2001 From: kali Date: Wed, 23 Jul 2025 11:20:46 +0200 Subject: [PATCH 42/49] refactor remote executor --- remote_rest/main.py | 1 - remote_rest/routers/commands.py | 4 +- remote_rest/utils.py | 3 - src/attackmate/attackmate.py | 16 +- .../executors/remote/remoteexecutor.py | 241 ++++++++++-------- src/attackmate/schemas/playbook.py | 2 +- 6 files changed, 152 insertions(+), 115 deletions(-) diff --git a/remote_rest/main.py b/remote_rest/main.py index 567c3b29..8af51041 100644 --- a/remote_rest/main.py +++ b/remote_rest/main.py @@ -20,7 +20,6 @@ from .auth_utils import create_access_token, get_user_hash, verify_password from .schemas import TokenResponse - CERT_DIR = os.path.dirname(os.path.abspath(__file__)) KEY_FILE = os.path.join(CERT_DIR, 'key.pem') CERT_FILE = os.path.join(CERT_DIR, 'cert.pem') diff --git a/remote_rest/routers/commands.py b/remote_rest/routers/commands.py index e85d60fe..ec703f01 100644 --- a/remote_rest/routers/commands.py +++ b/remote_rest/routers/commands.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException from src.attackmate.execexception import ExecException from src.attackmate.result import Result as AttackMateResult -from src.attackmate.schemas.commands import Command +from src.attackmate.schemas.command_types import Command from remote_rest.auth_utils import API_KEY_HEADER_NAME, get_current_user @@ -18,7 +18,7 @@ router = APIRouter(prefix='/command', tags=['Commands']) -logger = logging.getLogger(__name__) +logger = logging.getLogger('attackmate_api') class CommandRequest(BaseModel): diff --git a/remote_rest/utils.py b/remote_rest/utils.py index 7f727c19..376264f5 100644 --- a/remote_rest/utils.py +++ b/remote_rest/utils.py @@ -10,9 +10,6 @@ def varstore_to_state_model(varstore: VariableStore) -> VariableStoreStateModel: """Converts AttackMate VariableStore to Pydantic VariableStoreStateModel.""" - if not isinstance(varstore, VariableStore): - logger.error(f"Invalid type passed to varstore_to_state_model: {type(varstore)}") - return VariableStoreStateModel(variables={'error': 'Internal state error'}) combined_vars: Dict[str, Any] = {} combined_vars.update(varstore.variables) combined_vars.update(varstore.lists) diff --git a/src/attackmate/attackmate.py b/src/attackmate/attackmate.py index a6699165..261b2dad 100644 --- a/src/attackmate/attackmate.py +++ b/src/attackmate/attackmate.py @@ -11,15 +11,15 @@ import time from typing import Dict, Optional import logging -from .result import Result +from attackmate.result import Result import attackmate.executors as executors -from .schemas.config import CommandConfig, Config, MsfConfig, SliverConfig -from .schemas.playbook import Playbook -from attackmate.schemas.commands import Commands, Command -from .variablestore import VariableStore -from .processmanager import ProcessManager -from .executors.baseexecutor import BaseExecutor -from .executors.executor_factory import executor_factory +from attackmate.schemas.config import CommandConfig, Config, MsfConfig, SliverConfig +from attackmate.schemas.playbook import Playbook +from attackmate.schemas.command_types import Commands, Command +from attackmate.variablestore import VariableStore +from attackmate.processmanager import ProcessManager +from attackmate.executors.baseexecutor import BaseExecutor +from attackmate.executors.executor_factory import executor_factory import asyncio diff --git a/src/attackmate/executors/remote/remoteexecutor.py b/src/attackmate/executors/remote/remoteexecutor.py index 64b9077f..5dd821e8 100644 --- a/src/attackmate/executors/remote/remoteexecutor.py +++ b/src/attackmate/executors/remote/remoteexecutor.py @@ -12,122 +12,163 @@ from attackmate.processmanager import ProcessManager from attackmate.variablestore import VariableStore +output_logger = logging.getLogger('output') + @executor_factory.register_executor('remote') class RemoteExecutor(BaseExecutor): def __init__(self, pm: ProcessManager, varstore: VariableStore, cmdconfig=None): super().__init__(pm, varstore, cmdconfig) - # self.client is instantiated per command execution self.logger = logging.getLogger('playbook') - # Client class is instantiated per command execution with server_url context - self._clients_cache: Dict[str, RemoteAttackMateClient] = {} # Cache clients per server_url + # Client class is instantiated per command execution with server_url context and chached in + # client_cache + self._clients_cache: Dict[str, RemoteAttackMateClient] = {} def log_command(self, command: AttackMateRemoteCommand): self.logger.info( f"Executing REMOTE AttackMate command: Type='{command.type}', " f"RemoteCmd='{command.cmd}' on server {command.server_url}'" ) + remote_command_json = ( + command.remote_command.model_dump() if command.remote_command else ' ' + ) + output_logger.info( + f"Remote Command'{remote_command_json}' sent to server {command.server_url}'" + ) + + def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: + try: + client = self._get_remote_client(command) + response_data = self._dispatch_remote_command(client, command) + success, error_msg, stdout, return_code = self._process_response(response_data) - def _get_client(self, command_config: AttackMateRemoteCommand) -> RemoteAttackMateClient: + except (ExecException, IOError, FileNotFoundError) as e: + self.logger.error(f"Execution failed: {e}", exc_info=True) + success, error_msg, stdout, return_code = False, str(e), None, 1 + + except Exception as e: + error_message = f"Remote executor encountered an unexpected error: {e}" + self.logger.error(error_message, exc_info=True) + success, error_msg, stdout, return_code = False, error_message, None, 1 + + final_stdout = self._format_output(success, stdout, error_msg) + final_return_code = return_code if return_code is not None else (0 if success else 1) + + return Result(final_stdout, final_return_code) + + def _get_remote_client(self, command_config: AttackMateRemoteCommand) -> RemoteAttackMateClient: """Gets or creates a client instance for the given server URL.""" - server_url = self.varstore.substitute(command_config.server_url) # maybe better way? - if server_url not in self._clients_cache: - self.logger.info(f"Creating new remote client for server: {server_url}") - # Substitute user/password from local varstore if they are variables - user = self.varstore.substitute(command_config.user) if command_config.user else None - password = self.varstore.substitute(command_config.password) if command_config.password else None - cacert = self.varstore.substitute(command_config.cacert) if command_config.cacert else None - - self._clients_cache[server_url] = RemoteAttackMateClient( - server_url=server_url, - cacert=cacert, - username=user, - password=password - # maybe make Timeout configurable via AttackMateRemoteCommand? + server_url = self.varstore.substitute(command_config.server_url) + if server_url in self._clients_cache: + return self._clients_cache[server_url] + else: + self.logger.info( + f"Creating new remote client for server: {server_url}" ) + new_remote_client = self._create_remote_client(command_config) + self._clients_cache[server_url] = new_remote_client return self._clients_cache[server_url] - def _exec_cmd(self, command: AttackMateRemoteCommand) -> Result: - client = self._get_client(command) - response_data: Optional[Dict[str, Any]] = None - error_message: Optional[str] = None - success: bool = False - stdout_str: Optional[str] = None - return_code: int = 1 # Default to error + def _create_remote_client(self, command_config: AttackMateRemoteCommand) -> RemoteAttackMateClient: + """ + Creates and configures a new RemoteAttackMateClient + """ + server_url = self.varstore.substitute(command_config.server_url) + username = self.varstore.substitute(command_config.user) if command_config.user else None + password = ( + self.varstore.substitute(command_config.password) + if command_config.password else None + ) + cacert = self.varstore.substitute(command_config.cacert) if command_config.cacert else None + return RemoteAttackMateClient( + server_url=server_url, + username=username, + password=password, # noqa: E501 + # Split the line to adhere to the character limit + cacert=cacert + ) - # TODO make this properly configurable in AttackMateRemoteCommand for logging on server - api_call_debug_flag = False - if hasattr(command, 'debug') and isinstance(command.debug, bool): # add 'debug' as parameter to AttackMateRemoteCommand - api_call_debug_flag = command.debug + def _dispatch_remote_command( + self, client: 'RemoteAttackMateClient', command: AttackMateRemoteCommand + ) -> Dict[str, Any]: + """ + Dispatches the command to the appropriate client method. + """ + debug = getattr(command, 'debug', False) + self.logger.debug(f"Dispatching command '{command.cmd}' with debug={debug}") - try: - if command.cmd == 'execute_playbook_yaml': - # Substitute local vars into playbook content before sending? - # TODO decide on this, or if substitution happens only on the server side - - try: - if not command.playbook_yaml_content: - raise ExecException('playbook_yaml_content cannot be None.') - with open(command.playbook_yaml_content, 'r') as f: - yaml_content = f.read() - # TODO decide on varibale subsitution here - # final_yaml_content = self.varstore.substitute(yaml_content) - response_data = client.execute_remote_playbook_yaml(yaml_content, debug=api_call_debug_flag) - except Exception as e: - raise ExecException(f"Failed to read local file '{command.playbook_yaml_content}': {e}") - - elif command.cmd == 'execute_playbook_file': - if not command.playbook_file_path: - raise ExecException("playbook_file_path cannot be None for 'execute_playbook_file' command.") - response_data = client.execute_remote_playbook_file(command.playbook_file_path, - debug=api_call_debug_flag) - - elif command.cmd == 'execute_command': - response_data = client.execute_remote_command( - command_pydantic_model=command.remote_command, - debug=api_call_debug_flag - ) - - # Process response - if response_data: - cmd_result = response_data.get('result', {}) - if cmd_result: - success = cmd_result.get('success', False) - stdout_str = cmd_result.get('stdout') - return_code = cmd_result.get('returncode', 1 if not success else 0) - if not success and 'error_message' in cmd_result: - error_message = cmd_result['error_message'] - elif 'success' in response_data: # For playbook responses - success = response_data.get('success', False) - stdout_str = json.dumps(response_data, indent=2) # Whole response as stdout - return_code = 0 if success else 1 - if not success: - error_message = response_data.get('message') - else: - error_message = 'Received unexpected response structure from remote server.' - stdout_str = json.dumps(response_data, indent=2) - success = False - return_code = 1 - - else: # No response_data from client call - error_message = 'No response received from remote server (client communication failed).' - success = False - return_code = 1 - - except ExecException: - raise - except Exception as e: - error_message = f"Remote executor encountered an error: {e}" - self.logger.error(error_message, exc_info=True) - success = False - return_code = 1 - - # Finalize output, executir return whatever the remote server returns - if error_message and stdout_str and 'Error:' not in stdout_str: - stdout_str = f"Error: {error_message}\n\nOutput/Response:\n{stdout_str}" - elif error_message: - stdout_str = f"Error: {error_message}" - elif stdout_str is None: - stdout_str = 'Operation completed.' if success else 'Operation failed.' - - return Result(stdout_str, return_code if return_code is not None else (0 if success else 1)) + if command.cmd == 'execute_playbook_yaml' and command.playbook_yaml_content: + with open(command.playbook_yaml_content, 'r') as f: + yaml_content = f.read() + response = client.execute_remote_playbook_yaml(yaml_content, debug=debug) + + elif command.cmd == 'execute_playbook_file' and command.playbook_file_path: + response = client.execute_remote_playbook_file(command.playbook_file_path, debug=debug) + + elif command.cmd == 'execute_command': + response = client.execute_remote_command(command.remote_command, debug=debug) + + else: + raise ExecException(f"Unsupported remote command: '{command.cmd}'") + + return response if response is not None else {} + + def _process_response(self, response_data: Optional[Dict[str, Any]]) -> tuple: + """ + Processes the raw response from the remote client to determine success, + error message, return code, and stdout string. + """ + success: bool = False + error_message: Optional[str] = None + stdout_str: Optional[str] = None + return_code: int = 1 + + if not response_data: + error_message = 'No response received from remote server (client communication failed).' + self.logger.error(error_message) + return success, error_message, stdout_str, return_code + + self.logger.debug(f"Processing response data: {json.dumps(response_data)}") + + # Prioritize 'result' key for command-like responses + cmd_result = response_data.get('result', {}) + if cmd_result: + success = cmd_result.get('success', False) + stdout_str = cmd_result.get('stdout') + return_code = cmd_result.get('returncode', 1 if not success else 0) + if not success and 'error_message' in cmd_result: + error_message = cmd_result['error_message'] + self.logger.error(f"Remote command reported error: {error_message}") + else: + self.logger.info(f"Remote command execution success: {success}, return code: {return_code}") + + # Fallback to 'success' key for playbook-like responses + elif 'success' in response_data: + success = response_data.get('success', False) + stdout_str = json.dumps(response_data, indent=2) + return_code = 0 if success else 1 + if not success: + error_message = response_data.get('message', 'Unknown error during playbook execution.') + self.logger.error(f"Remote playbook execution failed: {error_message}") + else: + self.logger.info(f"Remote playbook execution success: {success}") + + # Catch all for unexpected response structures + else: + error_message = 'Received unexpected response structure from remote server.' + stdout_str = json.dumps(response_data, indent=2) + self.logger.warning(f"{error_message}: {stdout_str}") + + return success, error_message, stdout_str, return_code + + def _format_output(self, success: bool, stdout: Optional[str], error: Optional[str]) -> str: + """Creates the final stdout string based on the execution result.""" + if error: + # Prepend the error to the standard output if both exist + header = f"Error: {error}" + return f"{header}\n\nOutput/Response:\n{stdout}" if stdout else header + + if stdout is not None: + return stdout + + return 'Operation completed successfully.' if success else 'Operation failed with no output.' diff --git a/src/attackmate/schemas/playbook.py b/src/attackmate/schemas/playbook.py index 74e6ba1a..fd333d89 100644 --- a/src/attackmate/schemas/playbook.py +++ b/src/attackmate/schemas/playbook.py @@ -3,7 +3,7 @@ from .base import StrInt from pydantic import BaseModel -from .commands import Commands +from .command_types import Commands class Playbook(BaseModel): From 82ac2f808997c03e4c677d0189251ac0cad9846f Mon Sep 17 00:00:00 2001 From: kali Date: Fri, 1 Aug 2025 12:37:59 +0200 Subject: [PATCH 43/49] variable naming --- src/attackmate/logging_setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/attackmate/logging_setup.py b/src/attackmate/logging_setup.py index 6e9f3f13..1b8e2252 100644 --- a/src/attackmate/logging_setup.py +++ b/src/attackmate/logging_setup.py @@ -30,13 +30,13 @@ def initialize_logger(debug: bool, append_logs: bool): # output to console if not has_stdout_handler(playbook_logger): console_handler = logging.StreamHandler(sys.stdout) # Explicitly target stdout - formatter = ColoredFormatter(PLAYBOOK_CONSOLE_FORMAT, datefmt=DATE_FORMAT) - console_handler.setFormatter(formatter) + console_formatter = ColoredFormatter(PLAYBOOK_CONSOLE_FORMAT, datefmt=DATE_FORMAT) + console_handler.setFormatter(console_formatter) playbook_logger.addHandler(console_handler) # plain text output - formatter2 = logging.Formatter(DEFAULT_FILE_FORMAT, datefmt=DATE_FORMAT) - file_handler = create_file_handler(PLAYBOOK_LOG_FILE, append_logs, formatter2) + file_formatter = logging.Formatter(DEFAULT_FILE_FORMAT, datefmt=DATE_FORMAT) + file_handler = create_file_handler(PLAYBOOK_LOG_FILE, append_logs, file_formatter) playbook_logger.addHandler(file_handler) playbook_logger.propagate = False From 430205f65ccee891f918d2edc00067fea19f2328 Mon Sep 17 00:00:00 2001 From: kali Date: Fri, 1 Aug 2025 13:56:55 +0200 Subject: [PATCH 44/49] remove comment --- src/attackmate/executors/remote/remoteexecutor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/attackmate/executors/remote/remoteexecutor.py b/src/attackmate/executors/remote/remoteexecutor.py index 5dd821e8..cb3df121 100644 --- a/src/attackmate/executors/remote/remoteexecutor.py +++ b/src/attackmate/executors/remote/remoteexecutor.py @@ -84,7 +84,6 @@ def _create_remote_client(self, command_config: AttackMateRemoteCommand) -> Remo server_url=server_url, username=username, password=password, # noqa: E501 - # Split the line to adhere to the character limit cacert=cacert ) From 75a7d65584d89efa0e8e911b1b0411e87d295f96 Mon Sep 17 00:00:00 2001 From: Anna Erdi <61457816+annaerdi@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:52:06 +0200 Subject: [PATCH 45/49] Use local inline HTML for the browser-tests --- test/units/test_browserexecutor.py | 31 +++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/test/units/test_browserexecutor.py b/test/units/test_browserexecutor.py index 271c4ee3..a642a519 100644 --- a/test/units/test_browserexecutor.py +++ b/test/units/test_browserexecutor.py @@ -1,10 +1,27 @@ import pytest from pydantic import ValidationError from unittest.mock import patch, MagicMock +from urllib.parse import quote from attackmate.executors.browser.sessionstore import BrowserSessionStore, SessionThread from attackmate.executors.browser.browserexecutor import BrowserExecutor, BrowserCommand +# Minimal, stable inline HTML (no network needed) +HTML_SIMPLE = "

Hello World

" +HTML_WITH_LINK = """ + + + + go + + + + """ + +DATA_URL_SIMPLE = "data:text/html," + quote(HTML_SIMPLE) +DATA_URL_WITH_LINK = "data:text/html," + quote(HTML_WITH_LINK) + + @pytest.fixture def mock_playwright(): """ @@ -89,7 +106,7 @@ def test_session_thread_lifecycle(mock_playwright): thread = SessionThread(session_name='test_session', headless=True) # Submit a command. This should go onto the queue, be processed, and return 'OK' - result = thread.submit_command('visit', url='http://example.org') + result = thread.submit_command('visit', url=DATA_URL_SIMPLE) assert result == 'OK', "Expected the visit command to return 'OK'" # Stop the thread @@ -149,7 +166,7 @@ def test_browser_executor_ephemeral_session(browser_executor): command = BrowserCommand( type='browser', cmd='visit', - url='http://example.org', + url=DATA_URL_SIMPLE, headless=True ) result = browser_executor._exec_cmd(command) @@ -164,7 +181,7 @@ def test_browser_executor_named_session(browser_executor): create_cmd = BrowserCommand( type='browser', cmd='visit', - url='http://example.org', + url=DATA_URL_WITH_LINK, creates_session='my_session', headless=True ) @@ -176,7 +193,7 @@ def test_browser_executor_named_session(browser_executor): reuse_cmd = BrowserCommand( type='browser', cmd='click', - selector='a[href="https://www.iana.org/domains/example"]', + selector='#test-link', # matches the anchor in DATA_URL_WITH_LINK session='my_session' ) result2 = browser_executor._exec_cmd(reuse_cmd) @@ -207,7 +224,7 @@ def test_browser_executor_recreate_same_session(browser_executor): cmd1 = BrowserCommand( type='browser', cmd='visit', - url='http://example.org', + url=DATA_URL_SIMPLE, creates_session='my_session', headless=True ) @@ -217,7 +234,7 @@ def test_browser_executor_recreate_same_session(browser_executor): cmd2 = BrowserCommand( type='browser', cmd='visit', - url='http://example.com', + url=DATA_URL_WITH_LINK, creates_session='my_session', headless=True ) @@ -236,5 +253,5 @@ def test_browser_executor_unknown_command_validation(): BrowserCommand( type='browser', cmd='zoom', # invalid literal, should be one of [visit, click, type, screenshot] - url='http://example.org' + url=DATA_URL_SIMPLE ) From 3251062028c482c7fa3d1a9dd38e55efec92c8ad Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 28 Oct 2025 14:37:50 +0100 Subject: [PATCH 46/49] json und metadata logging for background commands --- src/attackmate/executors/features/background.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/attackmate/executors/features/background.py b/src/attackmate/executors/features/background.py index c2a9cb68..988b1240 100644 --- a/src/attackmate/executors/features/background.py +++ b/src/attackmate/executors/features/background.py @@ -1,3 +1,4 @@ +import json from attackmate.schemas.base import BaseCommand from attackmate.processmanager import ProcessManager from attackmate.result import Result @@ -30,7 +31,8 @@ def _create_queue(self) -> Optional[Queue]: def exec_background(self, command: BaseCommand) -> Result: self.logger.info(f'Run in background: {getattr(command, "type", "")}({command.cmd})') - + if command.metadata: + self.logger.info(f'Metadata: {json.dumps(command.metadata)}') queue = self._create_queue() if queue: From 4cf3e26400a695a62601bc86c19b308178c9157f Mon Sep 17 00:00:00 2001 From: kali Date: Tue, 28 Oct 2025 14:42:08 +0100 Subject: [PATCH 47/49] json und metadata logging for background commands --- src/attackmate/executors/baseexecutor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/attackmate/executors/baseexecutor.py b/src/attackmate/executors/baseexecutor.py index 1e33ee08..4f71d71d 100644 --- a/src/attackmate/executors/baseexecutor.py +++ b/src/attackmate/executors/baseexecutor.py @@ -81,13 +81,14 @@ def run(self, command: BaseCommand) -> Result: self.logger.debug(f"Template-Command: '{command.cmd}'") if command.background: # Background commands always return Result(None,None) + time_of_execution = datetime.now().isoformat() + self.log_json(self.json_logger, command, time_of_execution) result = self.exec_background(self.substitute_template_vars(command, self.substitute_cmd_vars)) else: result = self.exec(self.substitute_template_vars(command, self.substitute_cmd_vars)) return result - def log_command(self, command): """Log starting-status of the command""" self.logger.info(f"Executing '{command}'") From 090fe58989ae005cefe4051a09cca11e7076a5a3 Mon Sep 17 00:00:00 2001 From: kali Date: Wed, 29 Oct 2025 11:47:38 +0100 Subject: [PATCH 48/49] improve errors for playbook parsing --- src/attackmate/playbook_parser.py | 16 +++++++++++++++- src/attackmate/schemas/command_types.py | 10 +++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/attackmate/playbook_parser.py b/src/attackmate/playbook_parser.py index 40a3ba57..c50883ef 100644 --- a/src/attackmate/playbook_parser.py +++ b/src/attackmate/playbook_parser.py @@ -136,7 +136,21 @@ def parse_playbook(playbook_file: str, logger: logging.Logger) -> Playbook: except OSError: logger.error(f'Error: Could not open playbook file {target_file}') exit(1) - except ValidationError: + except ValidationError as e: logger.error(f'A Validation error occured when parsing playbook file {playbook_file}') + for error in e.errors(): + if error['type'] == 'missing': + logger.error( + f'Missing field in {error["loc"][-2]} command: {error["loc"][-1]} - {error["msg"]}' + ) + elif error['type'] == 'literal_error': + logger.error( + f'Invalid value in {error["loc"][-2]} command: {error["loc"][-1]} - {error["msg"]}' + ) + elif error['type'] == 'value_error': + logger.error( + f'Value error in command {int(error["loc"][-2]) + 1}: ' + f'{error["loc"][-1]} - {error["msg"]}' + ) logger.error(traceback.format_exc()) exit(1) diff --git a/src/attackmate/schemas/command_types.py b/src/attackmate/schemas/command_types.py index 0e1823c1..4a54728f 100644 --- a/src/attackmate/schemas/command_types.py +++ b/src/attackmate/schemas/command_types.py @@ -1,14 +1,18 @@ -from typing import List, TypeAlias, Union +from typing import List, Annotated, TypeAlias, Union +from pydantic import Field from attackmate.schemas.command_subtypes import RemotelyExecutableCommand from attackmate.schemas.remote import AttackMateRemoteCommand -Command: TypeAlias = Union[ +Command: TypeAlias = Annotated[ + Union[ RemotelyExecutableCommand, AttackMateRemoteCommand - ] + ], + Field(discriminator='type'), +] Commands: TypeAlias = List[Command] From 150cfbcc97d71ce47b28215d503f2cda7a3f1c5d Mon Sep 17 00:00:00 2001 From: Wolfgang Hotwagner Date: Wed, 26 Nov 2025 17:39:26 +0100 Subject: [PATCH 49/49] Fixed an issue with numeric metasploit-options Currently numeric metasploit-options should be automatically translated to integers. But after translating them to integers the next line tries .lower() them. This leads to errors, since integers can't be lowered as strings. --- src/attackmate/executors/metasploit/msfexecutor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/attackmate/executors/metasploit/msfexecutor.py b/src/attackmate/executors/metasploit/msfexecutor.py index 6c7f9ed8..89dc516a 100644 --- a/src/attackmate/executors/metasploit/msfexecutor.py +++ b/src/attackmate/executors/metasploit/msfexecutor.py @@ -88,6 +88,7 @@ def prepare_exploit(self, command: MsfModuleCommand): for option, setting in command.options.items(): if setting.isnumeric(): exploit[option] = int(setting) + continue if setting.lower() in ['true', 'false', '1', '0', 'y', 'n', 'yes', 'no']: exploit[option] = CmdVars.variable_to_bool(option, setting) else: